mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f | ||
|
|
160626b29d | ||
|
|
f1f1f8ad4d | ||
|
|
bb28a18f49 | ||
|
|
a3c0c9ee9b | ||
|
|
91fa1614ed | ||
|
|
87361a4573 | ||
|
|
6d0b6ad553 | ||
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa | ||
|
|
e402981708 | ||
|
|
453c439b74 | ||
|
|
ceba0f5849 | ||
|
|
3e0db8cf46 | ||
|
|
2a86830e72 | ||
|
|
43031ecf99 | ||
|
|
2b6c37f543 | ||
|
|
3458e2e72d | ||
|
|
0ac5928138 | ||
|
|
beb3b2e4e4 | ||
|
|
bf43476e88 | ||
|
|
6363125fec | ||
|
|
51d114a8da | ||
|
|
31be27f30e | ||
|
|
4c498d559c | ||
|
|
7fd3439b60 | ||
|
|
c4847c4d52 | ||
|
|
ad49e32de9 | ||
|
|
19854b2d76 | ||
|
|
085335ca14 | ||
|
|
9fb7e84d41 | ||
|
|
7ebe8e5210 | ||
|
|
922399b9a2 | ||
|
|
3b80e9bc8f | ||
|
|
dc8eb27c99 | ||
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
d13e74fc12 | ||
|
|
c0d211238b | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 | ||
|
|
edd62c5f7a | ||
|
|
5c1530b4b9 | ||
|
|
ce324510de | ||
|
|
274bfd0ca2 | ||
|
|
b8cfd30ff3 | ||
|
|
9fa88ff397 | ||
|
|
f105159186 | ||
|
|
a14a63c94c | ||
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb | ||
|
|
217738318e | ||
|
|
9291580fd6 | ||
|
|
812eb3d0bc | ||
|
|
31863a1da1 | ||
|
|
57a825887c | ||
|
|
1436c5f37e | ||
|
|
f0a43fb923 | ||
|
|
b47620651f | ||
|
|
2baad6720d | ||
|
|
c271a1897c | ||
|
|
d26dc8ada4 | ||
|
|
93f02672e6 | ||
|
|
307305282e | ||
|
|
a8ed6060af | ||
|
|
9dfe4c5450 | ||
|
|
da013c29d6 | ||
|
|
457a9d26f4 | ||
|
|
a68dae9422 | ||
|
|
e743cd60cf | ||
|
|
f9b1638603 | ||
|
|
6539c7314b | ||
|
|
903452a15b | ||
|
|
4e0fd3c0b1 | ||
|
|
a6f019ebe6 | ||
|
|
0f3c06f982 | ||
|
|
4f141c78ca | ||
|
|
ce60e006ac | ||
|
|
1612a9d07a | ||
|
|
378667fb37 | ||
|
|
3b81cc5240 | ||
|
|
d850265225 | ||
|
|
e32f72bfca | ||
|
|
2502af6e3c | ||
|
|
4757b957d2 | ||
|
|
8e54ed0517 | ||
|
|
9bbc994a4f | ||
|
|
ffa0fe698d | ||
|
|
25c46cfe0a | ||
|
|
573a29e596 | ||
|
|
ce2bc0ac84 | ||
|
|
b37d5e7928 | ||
|
|
18587124ac | ||
|
|
cd18029944 | ||
|
|
d6dd41407f | ||
|
|
60445d3b26 | ||
|
|
c2f93449ad | ||
|
|
4676a4e32b | ||
|
|
16fca10e6c | ||
|
|
dbc4d9a8c5 | ||
|
|
9c93860cd8 | ||
|
|
85ec91d6b3 | ||
|
|
d5d3b2da3f | ||
|
|
e5f3f612f8 | ||
|
|
3f74b95b34 | ||
|
|
7269cb5118 | ||
|
|
6d75a56b00 | ||
|
|
eb1c105bbf | ||
|
|
85e80d8aac | ||
|
|
23dc5c3723 | ||
|
|
50ae25bb88 | ||
|
|
9b2b9f24c5 | ||
|
|
ae6650f7e0 | ||
|
|
f029e652a1 | ||
|
|
9b3651e4e8 | ||
|
|
0dc476dfb7 | ||
|
|
de258cbd60 | ||
|
|
4ea18e9187 | ||
|
|
a6f36d56f3 | ||
|
|
77d0f254fc | ||
|
|
4eccdeeda6 | ||
|
|
473842eefc | ||
|
|
5e0f30cc2c | ||
|
|
b3e33a7002 | ||
|
|
f6456829fb | ||
|
|
327f538fed | ||
|
|
ad8b225cfd | ||
|
|
ea27f2bcd0 | ||
|
|
e4062cc97b | ||
|
|
c34cc93c45 | ||
|
|
7e3df7602d | ||
|
|
61a0d31408 | ||
|
|
644a47eb36 | ||
|
|
cd90c566cd | ||
|
|
1c262eb601 | ||
|
|
bf73a395e5 | ||
|
|
a5138639a8 | ||
|
|
988b78e810 | ||
|
|
2c46d62429 | ||
|
|
97743f17e3 | ||
|
|
3d10338807 | ||
|
|
d53ba1be21 | ||
|
|
c73d5c9436 | ||
|
|
8d92c69279 | ||
|
|
e32959e40a | ||
|
|
0b27c884a7 | ||
|
|
37c04f1e69 | ||
|
|
625776081b | ||
|
|
8cde79b8e3 | ||
|
|
eb53d122cc | ||
|
|
c64252193f | ||
|
|
cc70a21c05 | ||
|
|
644995cc56 | ||
|
|
50483bf3e1 | ||
|
|
4caba0a34c | ||
|
|
e8554d6434 | ||
|
|
ba1fdd65be | ||
|
|
0d4163b9bf | ||
|
|
f3c7a809f4 | ||
|
|
c1b5378045 | ||
|
|
3d6f53c16d | ||
|
|
8ceee5285b | ||
|
|
a181f1a5f0 | ||
|
|
7251f00e91 | ||
|
|
cb38f79aa3 | ||
|
|
54e9921efb | ||
|
|
f252b09757 | ||
|
|
a2992b3b9d | ||
|
|
7b67db5ced | ||
|
|
84c9ade8e3 | ||
|
|
9e26cd0285 | ||
|
|
73e60f1151 | ||
|
|
088e8f3b0d | ||
|
|
261d5dbf84 | ||
|
|
5f9d07aff1 | ||
|
|
3fb7b9be6b | ||
|
|
6c3a50ce7f | ||
|
|
f8304a0e86 | ||
|
|
e63f39f8c2 | ||
|
|
6f100dbe5e | ||
|
|
7c5ef269cd | ||
|
|
f13b6fc0ef | ||
|
|
3d61fc637b | ||
|
|
889b2aa4a9 |
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
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Nix
|
||||
|
||||
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
|
||||
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
|
||||
# from the Actions tab for any branch to get a build to try.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'crates/**'
|
||||
- 'po/**'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check --print-build-logs --no-build
|
||||
|
||||
- name: Build the package
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./result/bin/pdfs --version
|
||||
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
|
||||
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
|
||||
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
|
||||
+122
-22
@@ -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: |
|
||||
@@ -114,6 +153,9 @@ jobs:
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
po/build.sh deb/usr/share/locale
|
||||
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
|
||||
@@ -127,7 +169,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 +194,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,10 +233,67 @@ jobs:
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
|
||||
# pins, so it has to build before the release is announced.
|
||||
nix:
|
||||
name: Build the Nix package
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Build the flake
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Check the version
|
||||
run: |
|
||||
built="$(./result/bin/pdfs --version)"
|
||||
echo "$built"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
|
||||
esac
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release, nix]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
# The release notes are this version's section of the changelog, from its
|
||||
# heading up to the next one.
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
awk -v heading="## [$version]" \
|
||||
'index($0, heading) == 1 { found = 1; print; next }
|
||||
found && /^## \[/ { exit }
|
||||
found' docs/CHANGELOG.md > release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::error::docs/CHANGELOG.md has no section for $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
@@ -205,7 +305,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 +3,5 @@
|
||||
*.pdb
|
||||
.env
|
||||
/rpmbuild
|
||||
/result
|
||||
/result-*
|
||||
+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.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3485,13 +3485,15 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "2.6.0"
|
||||
version = "3.4.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.4.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.4.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.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3800,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.0"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
|
||||
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.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://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.2" }
|
||||
proton-drive-rs = { version = "0.8.2" }
|
||||
|
||||
# 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`.
|
||||
@@ -71,6 +76,7 @@ scriptable CLI.
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
```bash
|
||||
@@ -103,6 +109,7 @@ video thumbnails), headless use and uninstalling.
|
||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
@@ -144,7 +151,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 +159,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).
|
||||
|
||||
+4
-1
@@ -4,6 +4,9 @@
|
||||
|
||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||
|
||||
For what the client protects and what it does not, written for users, see the
|
||||
[security model](docs/SECURITY_MODEL.md).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Report privately through GitHub's
|
||||
@@ -31,7 +34,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
|
||||
+501
-61
@@ -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,
|
||||
pending_summary,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||
daemon_outdated, 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,
|
||||
@@ -121,17 +172,19 @@ enum Command {
|
||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||
mountpoint: Option<PathBuf>,
|
||||
},
|
||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
||||
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||
/// files download in the background; `pdfs transfers` shows the progress.
|
||||
/// Needs a running mount.
|
||||
Pin {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
/// File or folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Stop keeping a file on this device, evicting its cached content.
|
||||
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||
Unpin {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
/// File or folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// List files pinned to this device.
|
||||
/// List files and folders pinned to this device.
|
||||
Pins,
|
||||
/// List a directory via the running daemon (in-app browser backend).
|
||||
Ls {
|
||||
@@ -285,26 +338,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,
|
||||
@@ -569,6 +630,15 @@ enum DeviceCmd {
|
||||
#[arg(long, conflicts_with = "uid")]
|
||||
clear: bool,
|
||||
},
|
||||
/// Move another computer's folders into this computer's backup and sync
|
||||
/// them here. The other computer stays registered, without those folders.
|
||||
Migrate {
|
||||
/// Device uid (from `devices list`).
|
||||
uid: String,
|
||||
/// Skip the confirmation and accept every proposed local path.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -636,6 +706,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 +832,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 +891,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),
|
||||
@@ -884,6 +978,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
let uid = if clear { None } else { uid };
|
||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||
}
|
||||
DeviceCmd::Migrate { uid, yes } => {
|
||||
if !yes {
|
||||
println!(
|
||||
"Every folder that device backs up moves into this computer's backup.\n\
|
||||
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||
on that computer first, or it sees its folders disappear."
|
||||
);
|
||||
confirm("Move them here?")?;
|
||||
}
|
||||
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||
// A script gets the one reply and runs `sync restore` itself.
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(response)?;
|
||||
// The folders are this device's now, so the plain restore offers them.
|
||||
cmd_sync_restore(yes, None)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -984,7 +1096,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 +1184,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 +1198,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 +1830,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,20 +1842,32 @@ 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);
|
||||
|
||||
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
|
||||
// browser engine, so the user solves the page in their own browser and says
|
||||
// when they are done; the retry then carries the challenge token.
|
||||
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
|
||||
println!(
|
||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||
Open this page in your browser and complete it:\n\n {}\n",
|
||||
auth::browser_verification_url(&hv)
|
||||
);
|
||||
prompt_line("Press Enter once the page says you are verified")
|
||||
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
|
||||
Ok(auth::browser_verified(&hv))
|
||||
};
|
||||
|
||||
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))
|
||||
.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\
|
||||
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
||||
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
||||
your usual network often avoids it.)"
|
||||
),
|
||||
other => anyhow::Error::new(other).context("login failed"),
|
||||
})?;
|
||||
rt.block_on(auth::login_interactive(
|
||||
&username,
|
||||
&password,
|
||||
get_totp,
|
||||
get_mailbox,
|
||||
get_hv,
|
||||
))
|
||||
.context("login failed")?;
|
||||
|
||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||
// between keyring checks; restart it so the mount appears now instead of
|
||||
@@ -1657,9 +1879,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 +1988,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 +1999,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`.");
|
||||
@@ -1719,9 +2026,13 @@ fn cmd_status() -> Result<()> {
|
||||
staged_oldest_secs,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
let state = match (online, paused) {
|
||||
_ if session_expired => ", session expired: run `pdfs login`",
|
||||
(_, true) => ", sync paused",
|
||||
(false, false) => ", offline",
|
||||
(true, false) => "",
|
||||
@@ -1754,6 +2065,14 @@ fn cmd_status() -> Result<()> {
|
||||
failing_error.as_deref().unwrap_or("no error recorded"),
|
||||
);
|
||||
}
|
||||
if tokens_unsaved {
|
||||
println!(
|
||||
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
|
||||
);
|
||||
}
|
||||
if daemon_outdated(&version) {
|
||||
println!("Daemon {}", outdated_daemon_note(&version));
|
||||
}
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
Err(_) => println!("Mount: not running."),
|
||||
@@ -1761,6 +2080,20 @@ fn cmd_status() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
|
||||
/// an older release than this CLI.
|
||||
fn outdated_daemon_note(version: &str) -> String {
|
||||
let running = if version.is_empty() {
|
||||
"an older release".to_string()
|
||||
} else {
|
||||
version.to_string()
|
||||
};
|
||||
format!(
|
||||
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
}
|
||||
|
||||
fn cmd_locations() -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListLocations)?;
|
||||
if emit_json(&response)? {
|
||||
@@ -1828,7 +2161,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 +2186,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,25 +2206,31 @@ 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) => {
|
||||
tracing::info!("daemon stopping");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
|
||||
tracing::info!("resuming the session stored in the keyring");
|
||||
}
|
||||
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,13 +2288,22 @@ 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),
|
||||
},
|
||||
);
|
||||
|
||||
// Clean unmount check (as a best-effort final backup).
|
||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||
// Last chance for a rotation the keyring refused when it happened. Only
|
||||
// that: by now the keyring may hold a newer login, the one this stop was
|
||||
// restarted for, and the tokens of this session would overwrite it.
|
||||
if let Err(e) = auth::persist(&session) {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"could not save the refreshed session tokens; the next start may need `pdfs login`"
|
||||
);
|
||||
}
|
||||
// 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 +2797,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 +2901,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 +3417,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
|
||||
@@ -3066,11 +3460,21 @@ fn cmd_diagnose() -> Result<()> {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||
if daemon_outdated(&version) {
|
||||
report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" daemon version",
|
||||
outdated_daemon_note(&version),
|
||||
);
|
||||
}
|
||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
||||
let is_fuse = live_type
|
||||
.as_deref()
|
||||
@@ -3087,19 +3491,38 @@ fn cmd_diagnose() -> Result<()> {
|
||||
|kind| format!("{mountpoint} ({kind})"),
|
||||
),
|
||||
);
|
||||
report.finding(
|
||||
if online {
|
||||
DiagnoseLevel::Ok
|
||||
} else {
|
||||
DiagnoseLevel::Warn
|
||||
},
|
||||
" network",
|
||||
if online {
|
||||
"online"
|
||||
} else {
|
||||
"offline; cached data remains available"
|
||||
},
|
||||
);
|
||||
// The keyring check above read the blob, which still holds the
|
||||
// tokens the daemon could not replace.
|
||||
if tokens_unsaved {
|
||||
report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" session tokens",
|
||||
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
|
||||
);
|
||||
}
|
||||
// A refused session reads as offline too, but no connection will
|
||||
// bring it back, and the keyring check above cannot tell.
|
||||
if session_expired {
|
||||
report.finding(
|
||||
DiagnoseLevel::Fail,
|
||||
" session",
|
||||
"Proton refused it (expired or revoked) — run `pdfs login`",
|
||||
);
|
||||
} else {
|
||||
report.finding(
|
||||
if online {
|
||||
DiagnoseLevel::Ok
|
||||
} else {
|
||||
DiagnoseLevel::Warn
|
||||
},
|
||||
" network",
|
||||
if online {
|
||||
"online"
|
||||
} else {
|
||||
"offline; cached data remains available"
|
||||
},
|
||||
);
|
||||
}
|
||||
let pending = pending_uploads + pending_changes;
|
||||
report.finding(
|
||||
if pending == 0 {
|
||||
@@ -3334,6 +3757,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;
|
||||
@@ -3357,6 +3795,7 @@ mod login_wait_tests {
|
||||
size: 1,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
path: String::new(),
|
||||
@@ -3366,6 +3805,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
|
||||
+1018
-120
File diff suppressed because it is too large.
Load diff
+159
-43
@@ -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();
|
||||
@@ -2217,42 +2309,26 @@ mod tests {
|
||||
/// call also needs.
|
||||
#[test]
|
||||
fn budget_check_is_free_when_under_budget() {
|
||||
use std::time::Instant;
|
||||
|
||||
// Cap far above what we store, so every store stays under budget.
|
||||
let (c, _d) = cache_capped(1 << 30);
|
||||
let payload = vec![0u8; 4096];
|
||||
|
||||
// Cost of a store against a near-empty index...
|
||||
let t = Instant::now();
|
||||
for i in 0..500u64 {
|
||||
for i in 0..8u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let empty = t.elapsed() / 500;
|
||||
|
||||
// ...and against one with a few thousand entries in it.
|
||||
for i in 500..2500u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let t = Instant::now();
|
||||
for i in 2500..3000u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let full = t.elapsed() / 500;
|
||||
// Plant an index row far over the cap behind the running total's back,
|
||||
// and older than every real block. Any budget check that consults the
|
||||
// index would see the pool over budget, evict, and reseed the total.
|
||||
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
|
||||
|
||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
||||
// path does not grow with the index, and only a ratio tests that. An
|
||||
// absolute threshold measures the machine's load instead, and this test
|
||||
// runs alongside the rest of the suite.
|
||||
assert!(
|
||||
full < empty * 3,
|
||||
"under-budget store should not scale with the index: \
|
||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
||||
);
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||
.unwrap();
|
||||
|
||||
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||
}
|
||||
|
||||
/// The running totals are a fast path, not a second source of truth: after
|
||||
@@ -2561,6 +2637,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 +2689,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 +2769,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 +2836,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 +2901,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
||||
/// A failed upload must never cost the user their bytes:
|
||||
/// the scratch file moves to staging intact, and staging survives a reopen
|
||||
/// the way scratch deliberately does not.
|
||||
#[test]
|
||||
|
||||
+117
-36
@@ -3,7 +3,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::menu::PromptConfig;
|
||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||
|
||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||
/// without retiring every one.
|
||||
pub const APP_VERSION: &str = concat!(
|
||||
"external-drive-linux@",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"-stable"
|
||||
);
|
||||
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
|
||||
/// Keyring service name; one entry per credential kind keyed by username.
|
||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
pub app_version: String,
|
||||
pub user_agent: String,
|
||||
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||
/// next save.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub app_version: Option<String>,
|
||||
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub user_agent: Option<String>,
|
||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
@@ -154,6 +179,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.
|
||||
@@ -209,30 +241,28 @@ impl FileSort {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
upload_limit: None,
|
||||
download_limit: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_theme: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
online_map: false,
|
||||
}
|
||||
}
|
||||
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||
/// identity older releases stored as the default.
|
||||
fn identity_override<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||
/// [`APP_VERSION`].
|
||||
pub fn resolved_app_version(&self) -> &str {
|
||||
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||
}
|
||||
|
||||
/// The `User-Agent` sent to Proton: the user's override, or
|
||||
/// [`USER_AGENT`].
|
||||
pub fn resolved_user_agent(&self) -> &str {
|
||||
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||
}
|
||||
|
||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
@@ -424,7 +454,7 @@ impl AppDirs {
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
/// not something this client established (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
@@ -475,7 +505,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
/// other file (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
@@ -496,7 +526,7 @@ mod tests {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
@@ -563,8 +593,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
let config = AppConfig {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||
user_agent: Some("test-agent/1.0".to_string()),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
@@ -584,11 +614,15 @@ 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();
|
||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-test-client@1.0.0"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
@@ -619,8 +653,55 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_app_config() {
|
||||
let default_config = AppConfig::default();
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_identity_follows_the_release() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert_eq!(
|
||||
APP_VERSION,
|
||||
format!("external-drive-linux@{version}-stable")
|
||||
);
|
||||
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||
// Every config written up to 3.1.0 holds these two values, so they must
|
||||
// not pin the identity once Proton retires 0.1.0.
|
||||
let json = format!(
|
||||
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||
);
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(!saved.contains("app_version"), "{saved}");
|
||||
assert!(!saved.contains("user_agent"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_client_identity_is_kept() {
|
||||
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-linux@9.9.9-beta"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_client_identity_loads() {
|
||||
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,18 @@ 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> },
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, as server-side moves on the main volume. The other device stays
|
||||
/// registered, without those folders. Sync folders here that already track
|
||||
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||
/// Replies with [`Response::Ok`].
|
||||
MigrateDevice { uid: String },
|
||||
|
||||
// ---- device folder sync (devices.md) ----------------------------------
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||
AddSyncFolder { local_path: String },
|
||||
@@ -474,6 +484,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 +503,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 +896,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`]).
|
||||
@@ -1153,6 +1225,12 @@ pub struct JobItem {
|
||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||
/// May *grow* mid-job as more work is discovered.
|
||||
pub total: u64,
|
||||
/// Housekeeping that changes nothing in the user's files: a mirror folder
|
||||
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
|
||||
/// ends may show it, but should not report the drive as busy over it. Absent
|
||||
/// from older daemons, which never set it.
|
||||
#[serde(default)]
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||
@@ -1182,8 +1260,15 @@ pub struct DirEntry {
|
||||
pub size: u64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub modified: i64,
|
||||
/// Whether the file is pinned to this device.
|
||||
/// Whether the node is pinned to this device, on its own or through a
|
||||
/// pinned folder above it.
|
||||
pub pinned: bool,
|
||||
/// Whether the node is kept on this device because a folder above it is
|
||||
/// pinned. Such a node cannot be unpinned on its own. Set in
|
||||
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
|
||||
/// daemons predating the field.
|
||||
#[serde(default)]
|
||||
pub pinned_by_folder: bool,
|
||||
/// Whether the file's full content is present in the local cache (a current,
|
||||
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
||||
/// clients/daemons predating the field.
|
||||
@@ -1231,6 +1316,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
|
||||
@@ -1768,6 +1857,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
|
||||
/// older release than this build. Installing a package replaces the binaries
|
||||
/// but leaves the running user service on the old one, which keeps its bugs
|
||||
/// until it restarts, while the app and the CLI already describe the new
|
||||
/// release (#33). Empty means a daemon from before the field, so older too.
|
||||
///
|
||||
/// Only older counts. A newer daemon means this front-end is the one still
|
||||
/// running from before the upgrade, and restarting the service would not help.
|
||||
pub fn daemon_outdated(version: &str) -> bool {
|
||||
fn parse(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v.split('.').map(|p| p.parse().ok());
|
||||
let parsed = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(parsed)
|
||||
}
|
||||
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
|
||||
(Some(running), Some(this)) => running < this,
|
||||
(None, _) => version.is_empty(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One worker thread in a [`Diagnostics`] report.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct WorkerState {
|
||||
@@ -1834,17 +1944,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,
|
||||
@@ -1877,6 +1987,21 @@ pub enum Response {
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
/// True when Proton refused the session itself (refresh token refused,
|
||||
/// account deleted or disabled). `online` is false then too, but no
|
||||
/// connection brings it back: only signing in again does.
|
||||
#[serde(default)]
|
||||
session_expired: bool,
|
||||
/// True while the keyring refuses the session's refreshed tokens. The
|
||||
/// daemon keeps retrying; until it succeeds, a reboot signs the user
|
||||
/// out.
|
||||
#[serde(default)]
|
||||
tokens_unsaved: bool,
|
||||
/// The release of the daemon that answered. A package upgrade does not
|
||||
/// restart the user service, so it can be older than the front-end
|
||||
/// asking; daemons before 3.3.0 omit it, which reads as empty.
|
||||
#[serde(default)]
|
||||
version: String,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -2520,6 +2645,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #33: a daemon left running over an upgrade is named as older, also when
|
||||
/// it predates the version field, but a newer one is not, since restarting
|
||||
/// the service cannot bring it closer to this build.
|
||||
#[test]
|
||||
fn only_an_older_daemon_is_outdated() {
|
||||
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
|
||||
assert!(daemon_outdated(""));
|
||||
assert!(daemon_outdated("0.9.9"));
|
||||
assert!(!daemon_outdated("999.0.0"));
|
||||
assert!(!daemon_outdated("not-a-version"));
|
||||
let old: Response = serde_json::from_str(
|
||||
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
|
||||
"used":0,"budget":0,"pins":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let Response::Status { version, .. } = old else {
|
||||
panic!("not a status: {old:?}");
|
||||
};
|
||||
assert!(daemon_outdated(&version));
|
||||
}
|
||||
|
||||
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||
/// that is exactly how they cross the control socket.
|
||||
#[test]
|
||||
@@ -3013,6 +3159,9 @@ mod tests {
|
||||
uid: Some("dev-1".into()),
|
||||
},
|
||||
Request::AdoptDevice { uid: None },
|
||||
Request::MigrateDevice {
|
||||
uid: "dev-2".into(),
|
||||
},
|
||||
Request::ListRestorableFolders,
|
||||
Request::RestoreSyncFolders {
|
||||
items: vec![RestoreItem {
|
||||
@@ -3287,6 +3436,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 +3455,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",
|
||||
)?;
|
||||
|
||||
+817
-114
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>,
|
||||
@@ -139,6 +139,17 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||
/// after the folder moved to another device without changing its uid.
|
||||
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||
params![id, share_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
@@ -260,10 +271,92 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the baseline rows of `rel_path` and everything under it.
|
||||
///
|
||||
/// Used when a subtree leaves a mirror folder through a move on Drive: its
|
||||
/// local copy is about to be removed, and rows left behind would read as
|
||||
/// "deleted locally" and trash the moved nodes at their new place.
|
||||
pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
|
||||
/// in folder `to_folder`, in one transaction.
|
||||
///
|
||||
/// Used when a move on Drive took a mirror subtree along with its local
|
||||
/// copy: the rows still describe both sides correctly, only under a new
|
||||
/// path, so the next pass on either folder has nothing to do. The two
|
||||
/// folders may be the same one.
|
||||
pub fn sync_entries_move(
|
||||
&self,
|
||||
from_folder: i64,
|
||||
from_rel: &str,
|
||||
to_folder: i64,
|
||||
to_rel: &str,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut select = tx.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
)?;
|
||||
let rows = select
|
||||
.query_map(params![from_folder, from_rel], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut delete =
|
||||
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
|
||||
for row in &rows {
|
||||
delete.execute(params![from_folder, row.rel_path])?;
|
||||
}
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for row in rows {
|
||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||
insert.execute(params![
|
||||
to_folder,
|
||||
rel,
|
||||
row.remote_uid,
|
||||
row.local_mtime,
|
||||
row.local_size,
|
||||
row.remote_rev,
|
||||
row.remote_hash,
|
||||
row.local_mtime_ns,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
/// leaves an empty baseline + full remote = pure download.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
|
||||
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)).
|
||||
@@ -144,17 +154,17 @@ impl CoreError {
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A failure that is not an API response at all — a transport error, a
|
||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
||||
/// user their connection is down beats telling them the server refused.
|
||||
/// A transport error, a timeout or a TLS failure is what being offline
|
||||
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||
/// telling the user their connection is down beats telling them the
|
||||
/// server refused. The SDK's other failures say what went wrong instead
|
||||
/// (see [`proton_kind`]).
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that is not an `Api` variant never reached the
|
||||
// API; so does anything that is not a `ProtonError` at all.
|
||||
_ => ErrorKind::Offline,
|
||||
Some(proton) => proton_kind(proton),
|
||||
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||
None => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
}
|
||||
@@ -199,18 +209,38 @@ 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::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,
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton) => proton_kind(proton),
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a failed SDK call means to a front-end.
|
||||
///
|
||||
/// Only a transport failure is the connection. The rest happened on this
|
||||
/// machine, and calling them "offline" sent the user to check a network that
|
||||
/// worked (issue #38: a photo upload on an account without a photo library
|
||||
/// read "You're offline").
|
||||
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
|
||||
use proton_sdk::ProtonError as P;
|
||||
match e {
|
||||
P::Api(api) => api_kind(api.code),
|
||||
P::Transport(_) => ErrorKind::Offline,
|
||||
// Proton answered, but not with anything this client can read.
|
||||
P::Serialization(_) => ErrorKind::Remote,
|
||||
// The SDK refused the call before sending it, such as an upload to a
|
||||
// photo library the account does not have.
|
||||
P::InvalidOperation(_) => ErrorKind::Invalid,
|
||||
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an API response code means to a front-end.
|
||||
///
|
||||
/// The grouping is by *what the user can do about it*, which is what
|
||||
@@ -266,6 +296,23 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` says the session itself is gone: its refresh token was refused,
|
||||
/// or the account was deleted or disabled.
|
||||
///
|
||||
/// Nothing but a new login helps then, and no retry will ever succeed, so a
|
||||
/// caller must not take it for a lost connection and wait for it to come back
|
||||
/// (issue #35: a revoked session was shown as "offline" for good).
|
||||
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match e {
|
||||
proton_sdk::ProtonError::Api(api) => {
|
||||
api.is_invalid_refresh_token()
|
||||
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -297,6 +344,17 @@ mod tests {
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
|
||||
assert!(session_revoked(&api(C::InvalidRefreshToken)));
|
||||
assert!(session_revoked(&api(C::AccountDeleted)));
|
||||
assert!(session_revoked(&api(C::AccountDisabled)));
|
||||
// One file the account may not touch says nothing about the session.
|
||||
assert!(!session_revoked(&api(C::Forbidden)));
|
||||
assert!(!session_revoked(&api(C::Timeout)));
|
||||
assert!(!session_revoked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
@@ -327,6 +385,18 @@ mod tests {
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// Issue #38: the SDK refused a photo upload before sending it, and the
|
||||
/// app said "You're offline" while every other call went through.
|
||||
#[test]
|
||||
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
|
||||
let refused = ProtonError::invalid_operation("account has no photos volume");
|
||||
let e = CoreError::from_api(&refused, "upload photo failed");
|
||||
assert_eq!(e.kind, ErrorKind::Invalid);
|
||||
assert!(e.message.ends_with("account has no photos volume"));
|
||||
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
|
||||
assert_ne!(e.kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! A secret held in the Linux kernel keyring, for as long as the user is logged in.
|
||||
//!
|
||||
//! This is where the key passphrases of a two-password account live by default:
|
||||
//! the mailbox password is never stored, and the secrets derived from it are
|
||||
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
|
||||
//! when the user's last session ends or the machine reboots. Both the daemon and
|
||||
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
|
||||
//! It is also where session tokens wait while the Secret Service refuses them
|
||||
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
|
||||
//!
|
||||
//! Kernel keys are not paged to swap, are invisible to other users, and are
|
||||
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
|
||||
//! process's heap. Processes of the same user can still read them, exactly as
|
||||
//! they could ptrace the daemon: this protects against disk, backup and other
|
||||
//! users, not against code already running as you.
|
||||
//!
|
||||
//! The keyring may be unavailable (a container, a kernel built without
|
||||
//! `CONFIG_KEYS`); every operation then reports it as an error and the caller
|
||||
//! falls back to asking again, never to storing the secret somewhere weaker.
|
||||
|
||||
use std::io;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// One secret in the user keyring, by its description. Descriptions are
|
||||
/// namespaced so they cannot collide with another program's keys.
|
||||
pub struct Key {
|
||||
description: &'static [u8],
|
||||
}
|
||||
|
||||
/// The account's key passphrases (see the module docs).
|
||||
#[cfg(not(test))]
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases\0",
|
||||
};
|
||||
/// Tests must never touch a real unlock.
|
||||
#[cfg(test)]
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases:test\0",
|
||||
};
|
||||
|
||||
/// Session tokens the Secret Service refused to store, until it takes them.
|
||||
/// A refresh token held only in a process's memory dies with the process.
|
||||
#[cfg(not(test))]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens\0",
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens:test\0",
|
||||
};
|
||||
|
||||
const KEY_TYPE: &[u8] = b"user\0";
|
||||
|
||||
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
||||
const KEYCTL_REVOKE: libc::c_long = 3;
|
||||
const KEYCTL_SETPERM: libc::c_long = 5;
|
||||
const KEYCTL_SEARCH: libc::c_long = 10;
|
||||
const KEYCTL_READ: libc::c_long = 11;
|
||||
|
||||
/// Possessor and owner may view, read, write, search, link and set attributes;
|
||||
/// group and others get nothing.
|
||||
///
|
||||
/// A new key's default grants the owner only *view*, and read access to the
|
||||
/// possessor. A process possesses the user keyring only when its session
|
||||
/// keyring links to it, which a systemd user service or a container does not
|
||||
/// guarantee — so with the default, the daemon could store a key it then
|
||||
/// cannot read back. The owner is the same user that could ptrace the daemon
|
||||
/// anyway, so granting it read access gives up nothing.
|
||||
const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000;
|
||||
|
||||
fn last_error<T>() -> io::Result<T> {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
|
||||
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
|
||||
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SEARCH,
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
KEY_TYPE.as_ptr(),
|
||||
description.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if id >= 0 {
|
||||
return Ok(Some(id));
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
// ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone.
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop this process from being core-dumped or ptraced by an unprivileged
|
||||
/// process of the same user, so the secrets in its memory stay out of crash
|
||||
/// reports. Call once at start of a long-lived process that holds them.
|
||||
pub fn forbid_dumps() {
|
||||
// SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process.
|
||||
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
|
||||
if rc != 0 {
|
||||
tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable");
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
|
||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||
// describes `secret`, which outlives the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
self.description.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
)
|
||||
};
|
||||
if id < 0 {
|
||||
return last_error();
|
||||
}
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SETPERM,
|
||||
id,
|
||||
PERM_OWNER_AND_POSSESSOR,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = self.clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
for _ in 0..3 {
|
||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||
let len = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
std::ptr::null_mut::<u8>(),
|
||||
0usize,
|
||||
)
|
||||
};
|
||||
if len < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||
// SAFETY: `buf` is `len` writable bytes.
|
||||
let read = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if read < 0 {
|
||||
return last_error();
|
||||
}
|
||||
if read as usize <= buf.len() {
|
||||
buf.truncate(read as usize);
|
||||
return Ok(Some(buf));
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("the key changed while it was being read"))
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||
if rc < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The kernel keyring is process-wide state and a sandbox may not offer it,
|
||||
// so one test covers the whole round trip and skips when it is unavailable.
|
||||
#[test]
|
||||
fn stored_secret_round_trips_and_clears() {
|
||||
if PASSPHRASES.store(b"probe").is_err() {
|
||||
eprintln!("kernel keyring unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let saved = PASSPHRASES
|
||||
.load()
|
||||
.unwrap()
|
||||
.expect("stored secret is readable");
|
||||
assert_eq!(saved.as_slice(), b"probe");
|
||||
|
||||
PASSPHRASES.store(b"replaced").unwrap();
|
||||
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
|
||||
PASSPHRASES.clear().unwrap();
|
||||
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||
PASSPHRASES.clear().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::shutdown::Shutdown;
|
||||
use pdfs_core::error::session_revoked;
|
||||
|
||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||
@@ -9,7 +10,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 +98,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 +144,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 +232,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 +361,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 +376,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
|
||||
@@ -362,6 +403,12 @@ pub(super) async fn run_event_sync(
|
||||
}
|
||||
break Some(c.clone());
|
||||
}
|
||||
// Refused for good: retrying would be refused the same way
|
||||
// for the life of the daemon. The next login restarts it.
|
||||
Err(e) if session_revoked(&e) => {
|
||||
core.mark_session_expired("seed event cursor", &e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
@@ -377,10 +424,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 +445,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
|
||||
@@ -455,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
debug!("no photos volume; photo event sync not started");
|
||||
return;
|
||||
}
|
||||
Err(error) if session_revoked(&error) => {
|
||||
core.mark_session_expired("read the photos root", &error);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the photos root failed; retrying");
|
||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||
@@ -471,9 +529,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 +671,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;
|
||||
}
|
||||
@@ -662,7 +721,7 @@ fn scan_local_once(
|
||||
|
||||
// The walk has no idea how many files it will find, so the job counts what it
|
||||
// has seen and stays indeterminate.
|
||||
let job = transfers.begin_job("Indexing this computer");
|
||||
let job = transfers.begin_background_job("Indexing this computer");
|
||||
job.detail("Scanning your files");
|
||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||
@@ -715,6 +774,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");
|
||||
|
||||
+114
-30
@@ -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,10 @@ 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,
|
||||
session_expired: core.session_expired(),
|
||||
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
}
|
||||
@@ -342,7 +372,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
RefreshScope::Photos { .. } => &[],
|
||||
},
|
||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AddSyncFolder { .. }
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
@@ -350,6 +380,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 { .. }
|
||||
@@ -910,6 +941,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// The reply goes to the app's toast only; without
|
||||
// this the journal shows a failure and no reason.
|
||||
warn!(error = %e, "photo upload failed");
|
||||
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
||||
}
|
||||
}
|
||||
@@ -959,8 +993,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 +1008,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 +1051,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 +1066,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
|
||||
@@ -1249,6 +1303,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||
// Registering the device and uploading a folder tree far outlasts the
|
||||
// socket read timeout, so ack immediately and work on a background
|
||||
@@ -1345,7 +1403,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 +1415,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 +1826,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 +1918,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)]
|
||||
|
||||
+207
-48
@@ -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
|
||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
@@ -27,7 +28,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,
|
||||
};
|
||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||
}
|
||||
|
||||
/// The name a folder moved in from `device` takes when `name` is already
|
||||
/// taken under this device's root, or `None` when it can keep its own.
|
||||
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||
if free(name) {
|
||||
return None;
|
||||
}
|
||||
let base = format!("{name} ({device})");
|
||||
if free(&base) {
|
||||
return Some(base);
|
||||
}
|
||||
(2..)
|
||||
.map(|n| format!("{base} {n}"))
|
||||
.find(|candidate| free(candidate))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
@@ -140,7 +157,7 @@ impl Core {
|
||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||
let devices = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||
// No cached device row yet means this machine syncs nothing, so none of
|
||||
// the listed devices is ours.
|
||||
@@ -184,8 +201,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
|
||||
@@ -197,7 +214,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let dev = remote
|
||||
.into_iter()
|
||||
@@ -240,7 +257,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let d = remote
|
||||
.into_iter()
|
||||
@@ -260,7 +277,108 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, so this machine carries on backing them up as itself. The other
|
||||
/// device stays registered, without those folders.
|
||||
///
|
||||
/// Device roots all live on the main volume, so each folder is one
|
||||
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||
/// used twice. A folder whose name is taken here keeps its content under
|
||||
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||
/// second run picks up whatever is still left on the other device.
|
||||
///
|
||||
/// Nothing is synced to this disk here. The moved folders are now this
|
||||
/// device's, so the ordinary restore offers them. Only folders that were
|
||||
/// already synced here from the other device are touched: their uid
|
||||
/// survives the move but their share does not.
|
||||
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||
let source = self.restore_source(Some(uid))?;
|
||||
let target = self.ensure_device()?;
|
||||
if source.uid == target.uid {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is this computer's own device",
|
||||
source.name
|
||||
)));
|
||||
}
|
||||
let bad_root = |d: &StoredDevice| {
|
||||
parse_uid(&d.root_uid)
|
||||
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||
};
|
||||
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||
if source_root.volume_id != target_root.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
|
||||
let mut taken: Vec<String> = self
|
||||
.device_root_nodes(&target_root)?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
.map(|n| n.name)
|
||||
.collect();
|
||||
let synced = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let mut moved = 0usize;
|
||||
let mut renamed: Vec<String> = Vec::new();
|
||||
let mut left: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
for node in self.device_root_nodes(&source_root)? {
|
||||
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||
continue;
|
||||
}
|
||||
// A device root only ever holds folders (B68); anything else was
|
||||
// put there by hand and is left for the user to deal with.
|
||||
if !node.is_folder() {
|
||||
left.push(node.name);
|
||||
continue;
|
||||
}
|
||||
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||
failed.push(format!("{}: {e}", node.name));
|
||||
continue;
|
||||
}
|
||||
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||
if new_name.is_some() {
|
||||
renamed.push(format!("{} -> {landed}", node.name));
|
||||
}
|
||||
taken.push(landed);
|
||||
moved += 1;
|
||||
|
||||
let uid = node.uid.to_string();
|
||||
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||
}
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
}
|
||||
self.invalidate_children_of(&source_root);
|
||||
self.invalidate_children_of(&target_root);
|
||||
|
||||
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||
if !renamed.is_empty() {
|
||||
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||
}
|
||||
if !left.is_empty() {
|
||||
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||
}
|
||||
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||
"device migrated");
|
||||
if moved == 0 && !failed.is_empty() {
|
||||
return Err(CoreError::conflict(message));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||
@@ -270,15 +388,14 @@ 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
|
||||
// any cached record, and recover an existing device by name.
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
|
||||
// An adopted uid is an explicit instruction, so it outranks everything
|
||||
@@ -337,7 +454,7 @@ impl Core {
|
||||
None => {
|
||||
let d = self
|
||||
.rt
|
||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
||||
.block_on(self.drive.create_device(&name, DeviceType::Linux))
|
||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||
StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
@@ -361,23 +478,27 @@ impl Core {
|
||||
root_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
Ok(self
|
||||
.device_root_nodes(root_uid)?
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Everything directly under a device root, trashed nodes included.
|
||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||
}
|
||||
|
||||
/// Add a local folder to this device's sync set: register the device if
|
||||
/// needed, create a matching folder under the device root, upload the local
|
||||
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
||||
@@ -446,10 +567,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}")))?,
|
||||
};
|
||||
|
||||
@@ -539,12 +657,16 @@ impl Core {
|
||||
} else {
|
||||
p.total.max(p.done) as u64
|
||||
},
|
||||
// Most passes find nothing; only one that queues work is
|
||||
// the drive syncing.
|
||||
background: true,
|
||||
},
|
||||
SyncPhase::Applying => JobItem {
|
||||
title: format!("Syncing {folder}"),
|
||||
detail: p.current.clone(),
|
||||
done: p.done as u64,
|
||||
total: p.total.max(p.done) as u64,
|
||||
background: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -603,7 +725,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 +741,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
|
||||
}
|
||||
|
||||
@@ -853,12 +968,29 @@ impl Core {
|
||||
if folder.state != "idle" {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
// `idle` only says the last pass applied everything it tried.
|
||||
// It never tries an ignored path, a symlink or a folder it
|
||||
// cannot read, it defers a file open for writing, and a file can
|
||||
// change after it ends. The eviction below deletes all of those
|
||||
// for good, so it waits until nothing here is missing from Drive
|
||||
// (B198).
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
if let Some(reason) =
|
||||
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||
{
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"going online-only would delete what is only on this computer: {reason}"
|
||||
)));
|
||||
}
|
||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||
})?;
|
||||
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 +1069,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 +1128,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 +1148,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,
|
||||
@@ -1119,10 +1250,38 @@ mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||
let taken = vec!["Pictures".to_string()];
|
||||
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||
let taken = vec!["Documents".to_string()];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||
let taken = vec![
|
||||
"Documents".to_string(),
|
||||
"Documents (DESKTOP-1)".to_string(),
|
||||
"Documents (DESKTOP-1) 2".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1) 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
|
||||
+1653
-267
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,490 @@
|
||||
//! The calls the sync core makes to Drive, behind a trait.
|
||||
//!
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||
//! stay on the concrete client (`Core::client`) until a test needs them faked.
|
||||
//! Listing and registering devices are here, so a simulated daemon can sync a
|
||||
//! local folder.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
//! of any type) cannot be part of an object-safe trait, so the trait takes them
|
||||
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt as _;
|
||||
use futures::stream::BoxStream;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
||||
RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
|
||||
|
||||
/// The outcomes of a streaming batch request, one item per node as it lands.
|
||||
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
|
||||
|
||||
/// Drive as the sync core sees it. See the module documentation.
|
||||
#[async_trait]
|
||||
pub(crate) trait DriveApi: Send + Sync {
|
||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
|
||||
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>>;
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>>;
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a>;
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
|
||||
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Every computer that backs up to the account.
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
|
||||
|
||||
/// Register a computer, with a root folder of its own named `name`.
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
/// worker it is called on.
|
||||
#[cfg(test)]
|
||||
fn landing(&self, _uid: &NodeUid) {}
|
||||
}
|
||||
|
||||
/// The generic signatures of the client, over the object-safe ones above.
|
||||
impl dyn DriveApi {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_replacing_draft_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
self.upload_new_revision_from_dyn(
|
||||
file_uid,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file_to<W: Write + Send>(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_file_to_dyn(uid, output).await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_revision_to<W: Write + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_revision_to_dyn(file_uid, revision_id, writer)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An open revision that block reads are served from; see [`RevisionReader`].
|
||||
#[async_trait]
|
||||
pub(crate) trait RevisionRead: Send + Sync {
|
||||
/// Plaintext size of the revision.
|
||||
fn size(&self) -> u64;
|
||||
|
||||
/// Plaintext size of each block, in block order.
|
||||
fn block_sizes(&self) -> &[u64];
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RevisionRead for RevisionReader {
|
||||
fn size(&self) -> u64 {
|
||||
RevisionReader::size(self)
|
||||
}
|
||||
|
||||
fn block_sizes(&self) -> &[u64] {
|
||||
RevisionReader::block_sizes(self)
|
||||
}
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
RevisionReader::read_at(self, offset, length).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriveApi for ProtonDriveClient {
|
||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||
ProtonDriveClient::get_my_files_folder(self).await
|
||||
}
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
|
||||
ProtonDriveClient::get_node(self, uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes_light(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>> {
|
||||
ProtonDriveClient::enumerate_events(self, scope, cursor).await
|
||||
}
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
|
||||
ProtonDriveClient::invalidate_caches_for_event(self, event).await
|
||||
}
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
|
||||
}
|
||||
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_replacing_draft_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::upload_new_revision_from(
|
||||
self,
|
||||
file_uid,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||
let reader = ProtonDriveClient::open_revision(self, uid).await?;
|
||||
Ok(Arc::new(reader))
|
||||
}
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
|
||||
}
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
|
||||
}
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
|
||||
}
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::trash_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::restore_nodes(self, uids).await
|
||||
}
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_trash_node_uids(self).await
|
||||
}
|
||||
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||
ProtonDriveClient::enumerate_devices(self).await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||
ProtonDriveClient::create_device(self, name, device_type).await
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -223,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
@@ -235,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}"
|
||||
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
@@ -313,6 +327,9 @@ mod tests {
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
version: "3.3.0".into(),
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
|
||||
+372
-261
File diff suppressed because it is too large.
Load diff
+2523
-875
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,436 @@
|
||||
//! Noticing that the network went away mid-session, and that it came back.
|
||||
//!
|
||||
//! The mount used to learn it was offline only at startup: a daemon that had
|
||||
//! connected once kept `online` true for the rest of its life, so a Wi-Fi drop
|
||||
//! turned every create, mkdir, delete and rename into a two-minute wait for an
|
||||
//! `EIO`, while the queue that exists for exactly this sat unused. Now any
|
||||
//! remote call a handler waits on reports a failure to reach Proton through
|
||||
//! [`Core::lost_link`], which flips the mount offline; the handler then takes its
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use pdfs_core::error::session_revoked;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
|
||||
|
||||
/// How long a remote call a FUSE handler is waiting on may take before the link
|
||||
/// is treated as down.
|
||||
///
|
||||
/// The SDK's own budget for one request is four attempts of up to 30 seconds
|
||||
/// each, so a dead link held the caller for about two minutes. A metadata call
|
||||
/// that has had no answer in 20 seconds is not going to be one the user wants to
|
||||
/// keep waiting for; queueing the change is the better answer.
|
||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||
/// back by as long as it waits.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// First delay between probes after a drop seen mid-session, and the ceiling it
|
||||
/// doubles to while the outage is young. A blip should end in seconds, not in
|
||||
/// the minutes [`ONLINE_PROBE_MAX`] allows.
|
||||
const PROBE_AFTER_DROP: Duration = Duration::from_secs(2);
|
||||
const PROBE_YOUNG_MAX: Duration = Duration::from_secs(30);
|
||||
/// How long an outage counts as young. Past it the probe backs off to
|
||||
/// [`ONLINE_PROBE_MAX`], because a laptop can sit offline for days and each
|
||||
/// probe is a real API round trip.
|
||||
const YOUNG_OUTAGE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// How often the idle probe thread checks for teardown while the link is up.
|
||||
const IDLE_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The shared half of the online flag: when the link went down, and a condvar
|
||||
/// that fires on every change so waiters need not poll.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Link {
|
||||
lost_at: Mutex<Option<Instant>>,
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
/// Proton refused the session itself ([`session_revoked`]). The mount is
|
||||
/// offline then, but no probe brings it back: only a new login does, and
|
||||
/// that restarts the daemon. Read by front-ends through `Response::Status`,
|
||||
/// so they can ask the user to sign in again rather than say "offline".
|
||||
pub(crate) session_expired: AtomicBool,
|
||||
/// Proton refused the session, but only because another process rotated
|
||||
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
|
||||
/// ends the mount for the daemon to mount again from the keyring.
|
||||
pub(crate) resume_stored: AtomicBool,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// The link of a mount that comes up with Proton refusing its session, or
|
||||
/// not.
|
||||
pub(crate) fn starting(session_expired: bool) -> Self {
|
||||
Self {
|
||||
session_expired: AtomicBool::new(session_expired),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
/// than because it answered and refused.
|
||||
///
|
||||
/// A transport error is the network. So is a request timeout: the SDK reports
|
||||
/// Proton's own "try again" codes that way, and [`no_answer`] reports our
|
||||
/// deadline that way. Anything else Proton actually said, and says again on a
|
||||
/// retry, so it must reach the caller rather than be queued.
|
||||
pub(crate) fn is_network_error(e: &ProtonError) -> bool {
|
||||
match e {
|
||||
ProtonError::Transport(_) => true,
|
||||
ProtonError::Api(api) => {
|
||||
matches!(
|
||||
api.code,
|
||||
ResponseCode::RequestTimeout | ResponseCode::Timeout
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a call reports when it runs past `after` without an answer.
|
||||
pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::RequestTimeout,
|
||||
http_status: 408,
|
||||
message: format!("no answer within {}s", after.as_secs()),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The slowest upload rate an upload's deadline allows for, past the
|
||||
/// metadata budget it starts with. Far below any link that works, so only a
|
||||
/// transfer that has stopped moving runs into it.
|
||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||
|
||||
/// How long an upload of `len` bytes may take before the link is treated as
|
||||
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||
pub(crate) async fn bounded<T>(
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
bounded_by(INTERACTIVE_CALL_TIMEOUT, call).await
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`.
|
||||
pub(crate) async fn bounded_by<T>(
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
match tokio::time::timeout(limit, call).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(limit)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next delay between probes, given the current one and how long the
|
||||
/// outage has lasted.
|
||||
fn next_probe_delay(delay: Duration, outage: Duration) -> Duration {
|
||||
let cap = if outage < YOUNG_OUTAGE {
|
||||
PROBE_YOUNG_MAX
|
||||
} else {
|
||||
ONLINE_PROBE_MAX
|
||||
};
|
||||
(delay * 2).min(cap)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Whether the mount currently believes Proton is reachable.
|
||||
pub(crate) fn is_online(&self) -> bool {
|
||||
self.online.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether a namespace change through the mount goes to Drive inside the
|
||||
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
|
||||
/// otherwise it is queued and the drain sends it.
|
||||
///
|
||||
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
|
||||
pub(crate) fn sends_inline(&self) -> bool {
|
||||
!self.local_first && self.is_online()
|
||||
}
|
||||
|
||||
/// Run a remote call a caller is waiting on, giving up after
|
||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||
/// FUSE worker has no reactor of its own.
|
||||
pub(crate) fn block_on_bounded<T>(
|
||||
&self,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded(call))
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`: an upload, whose budget
|
||||
/// grows with its size ([`upload_deadline`]).
|
||||
pub(crate) fn block_on_within<T>(
|
||||
&self,
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded_by(limit, call))
|
||||
}
|
||||
|
||||
/// Whether Proton refused the session itself, so nothing reaches it until
|
||||
/// the user signs in again.
|
||||
pub(crate) fn session_expired(&self) -> bool {
|
||||
self.link.session_expired.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, or of a session, in which case the mount is now offline and the
|
||||
/// caller should take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if session_revoked(e) {
|
||||
self.mark_session_expired(what, e);
|
||||
return true;
|
||||
}
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
self.mark_offline(what, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline for good: Proton refused the session, and only a
|
||||
/// new login brings it back. Changes stay queued for the daemon that login
|
||||
/// starts. Only the first caller logs it.
|
||||
///
|
||||
/// Unless the keyring holds a newer session than the refused one: then
|
||||
/// the mount ends, for the daemon to mount again on that one.
|
||||
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
if self.link.resume_stored.load(Ordering::Relaxed) {
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if pdfs_core::auth::stored_session_is_newer() {
|
||||
info!(during = what, %error,
|
||||
"Proton refused a session another process has since refreshed; resuming it");
|
||||
self.link.resume_stored.store(true, Ordering::Relaxed);
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
|
||||
warn!(during = what, %error,
|
||||
"Proton refused the session; run `pdfs login` to sign in again");
|
||||
}
|
||||
self.mark_offline(what, error);
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
if self.online.swap(false, Ordering::Relaxed) {
|
||||
*lost_at = Some(Instant::now());
|
||||
warn!(during = what, %error,
|
||||
"lost the connection to Proton; queueing changes until it returns");
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the mount back online, wake everything waiting for it, and let the
|
||||
/// queue drain.
|
||||
fn mark_online(&self) {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
match lost_at.take() {
|
||||
Some(since) => {
|
||||
info!(
|
||||
offline_secs = since.elapsed().as_secs(),
|
||||
"connection to Proton is back"
|
||||
)
|
||||
}
|
||||
None => info!("back online"),
|
||||
}
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
self.relist_offline_listings();
|
||||
// Anything written while offline is queued and waiting on exactly this,
|
||||
// including ops that failed on the way down and are sitting out a
|
||||
// backoff sized for a server that keeps refusing.
|
||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
self.wake_drain();
|
||||
}
|
||||
|
||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||
pub(crate) fn wait_online(&self, budget: Duration) -> bool {
|
||||
let deadline = Instant::now() + budget;
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while !self.is_online() {
|
||||
if self.shutdown.is_stopping()
|
||||
|| self
|
||||
.link
|
||||
.changed
|
||||
.wait_until(&mut lost_at, deadline)
|
||||
.timed_out()
|
||||
{
|
||||
return self.is_online();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Wait out `interval` before a background poll of Proton, and while the
|
||||
/// link is down, until it is back. Returns as soon as it comes back, so the
|
||||
/// poll catches up on what changed during the outage at once instead of
|
||||
/// one interval later. A poll into a dead link would only wait out the
|
||||
/// SDK's retries and fail.
|
||||
pub(crate) async fn next_poll(&self, interval: Duration) {
|
||||
let due = Instant::now() + interval;
|
||||
let mut was_offline = false;
|
||||
loop {
|
||||
if !self.is_online() {
|
||||
was_offline = true;
|
||||
} else if was_offline || Instant::now() >= due {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(IDLE_CHECK).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down.
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
pub(crate) fn run_online_probe(&self) {
|
||||
// Only an offline start never had a chance to repair its share id.
|
||||
let mut repair_share_id = !self.is_online();
|
||||
loop {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while self.is_online() {
|
||||
if self.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
self.link.changed.wait_for(&mut lost_at, IDLE_CHECK);
|
||||
}
|
||||
}
|
||||
if !self.probe_until_online() {
|
||||
return;
|
||||
}
|
||||
if std::mem::take(&mut repair_share_id) {
|
||||
self.repair_primary_share_id(&self.primary_root_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
///
|
||||
/// A refused session is not probed: every probe would be refused the same
|
||||
/// way, forever (issue #35). The thread waits for the stop instead.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
PROBE_AFTER_DROP
|
||||
} else {
|
||||
ONLINE_PROBE_MIN
|
||||
};
|
||||
loop {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
if self.session_expired() {
|
||||
delay = ONLINE_PROBE_MAX;
|
||||
continue;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(PROBE_TIMEOUT)),
|
||||
}
|
||||
});
|
||||
match probe {
|
||||
Ok(root) => {
|
||||
{
|
||||
let root_ino = self.root_ino();
|
||||
let mut st = self.state();
|
||||
if let Some(e) = st.entries.get_mut(&root_ino) {
|
||||
e.node = root.clone();
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&root) {
|
||||
warn!(error = %e, "refresh root after reconnect failed");
|
||||
}
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api(code: ResponseCode) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: String::new(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_unanswered_call_counts_as_a_lost_link() {
|
||||
assert!(is_network_error(&no_answer(INTERACTIVE_CALL_TIMEOUT)));
|
||||
assert!(is_network_error(&api(ResponseCode::Timeout)));
|
||||
assert!(!is_network_error(&api(ResponseCode::AlreadyExists)));
|
||||
assert!(!is_network_error(&api(ResponseCode::InvalidRequirements)));
|
||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30),
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let limit = Duration::from_millis(20);
|
||||
let result: Result<(), _> = rt.block_on(bounded_by(limit, std::future::pending()));
|
||||
assert!(result.is_err_and(|e| is_network_error(&e)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||
let young = Duration::from_secs(60);
|
||||
let old = YOUNG_OUTAGE + Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
next_probe_delay(PROBE_AFTER_DROP, young),
|
||||
2 * PROBE_AFTER_DROP
|
||||
);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, young), PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, old), 2 * PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(ONLINE_PROBE_MAX, old), ONLINE_PROBE_MAX);
|
||||
}
|
||||
}
|
||||
+251
-88
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||
/// The caller may want to remount.
|
||||
Unmounted,
|
||||
/// Proton refused the session, and the keyring holds a newer one: another
|
||||
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
|
||||
/// unmounted ourselves; the caller should mount again, which resumes it.
|
||||
ResumeStored,
|
||||
}
|
||||
|
||||
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
|
||||
@@ -193,7 +197,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
||||
}
|
||||
|
||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
@@ -207,33 +211,63 @@ const ROOT_UID_KEY: &str = "root_uid";
|
||||
/// means an empty tree, and mounting that would show the user an empty Drive
|
||||
/// rather than an honest error.
|
||||
fn fetch_or_recall_root(
|
||||
client: &ProtonDriveClient,
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> std::io::Result<(Node, bool)> {
|
||||
let err = match rt.block_on(client.get_my_files_folder()) {
|
||||
) -> std::io::Result<(Node, Start)> {
|
||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||
warn!(error = %e, "persist root uid failed");
|
||||
}
|
||||
return Ok((root, true));
|
||||
return Ok((root, Start::Online));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
// Still mounted from the cache, so cached files stay readable and changes
|
||||
// queue for the daemon the next login starts.
|
||||
let start = if pdfs_core::error::session_revoked(&err) {
|
||||
Start::SessionExpired
|
||||
} else {
|
||||
Start::Offline
|
||||
};
|
||||
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Proton refused a session another process has since refreshed ({err}); resuming it"
|
||||
)));
|
||||
}
|
||||
let cached = db
|
||||
.state_str(ROOT_UID_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||
match cached {
|
||||
Some(root) if start == Start::SessionExpired => {
|
||||
warn!(error = %err,
|
||||
"Proton refused the session; mounting from cache until `pdfs login`");
|
||||
Ok((root, start))
|
||||
}
|
||||
Some(root) => {
|
||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
||||
Ok((root, false))
|
||||
Ok((root, start))
|
||||
}
|
||||
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
|
||||
"Proton refused the session ({err}); run `pdfs login`"
|
||||
))),
|
||||
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mount comes up, from what fetching its root said.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Start {
|
||||
Online,
|
||||
/// Proton could not be reached: serve the cache and probe for the link.
|
||||
Offline,
|
||||
/// Proton refused the session: serve the cache until a new login.
|
||||
SessionExpired,
|
||||
}
|
||||
|
||||
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||
/// caller, so `mount` itself never reads config or the environment.
|
||||
pub struct MountOptions {
|
||||
@@ -244,6 +278,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 +313,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 +377,58 @@ 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, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let online = start == Start::Online;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
@@ -357,19 +436,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 +464,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(link::Link::starting(start == Start::SessionExpired)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
creating: Arc::new(Mutex::new(HashMap::new())),
|
||||
sealed_unread: Arc::new(Mutex::new(HashMap::new())),
|
||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
landing_uploads: Arc::new(AtomicU64::new(0)),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
timeline_progress: Arc::new(Mutex::new(None)),
|
||||
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||
@@ -398,7 +485,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 +498,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()),
|
||||
@@ -422,10 +510,11 @@ pub fn mount(
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's 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 +537,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 +573,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 +587,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 +711,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,6 +732,82 @@ 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_rx = match host {
|
||||
Host::Daemon => signal_stop(&rt),
|
||||
#[cfg(test)]
|
||||
Host::Simulation(rx) => rx,
|
||||
};
|
||||
|
||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||
// mount ending on its own (→ the session thread finishes). Poll instead of
|
||||
// blocking on `join` so we can also notice the signal.
|
||||
let outcome = loop {
|
||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(()) => {
|
||||
info!("stop requested; unmounting");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if core.link.resume_stored.load(Ordering::Relaxed) =>
|
||||
{
|
||||
info!("unmounting to resume the stored session");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::ResumeStored;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
|
||||
warn!(error = %e, "session join failed");
|
||||
}
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Signal task gone (failed to install); fall back to join.
|
||||
core.mounts.lock().close();
|
||||
let _ = teardown_session(&core.session_live, || bg.join());
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY.
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
warn!("on-demand restore worker panicked");
|
||||
}
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(e) = mount.teardown() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
core.queue_unreleased_writes();
|
||||
|
||||
// Last, because the workers may legitimately be finishing an upload, and
|
||||
// nothing they can still do is harmful once the mounts are down.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// A channel that receives once SIGTERM or SIGINT arrives.
|
||||
fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
|
||||
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
rt.spawn(async move {
|
||||
let mut sigterm =
|
||||
@@ -664,65 +832,13 @@ pub fn mount(
|
||||
}
|
||||
let _ = sig_tx.try_send(());
|
||||
});
|
||||
|
||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||
// mount ending on its own (→ the session thread finishes). Poll instead of
|
||||
// blocking on `join` so we can also notice the signal.
|
||||
let outcome = loop {
|
||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(()) => {
|
||||
info!("stop requested; unmounting");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
|
||||
warn!(error = %e, "session join failed");
|
||||
}
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Signal task gone (failed to install); fall back to join.
|
||||
core.mounts.lock().close();
|
||||
let _ = teardown_session(&core.session_live, || bg.join());
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
warn!("on-demand restore worker panicked");
|
||||
}
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(e) = mount.teardown() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Last, because the workers may legitimately be finishing an upload, and
|
||||
// nothing they can still do is harmful once the mounts are down.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
Ok(outcome)
|
||||
sig_rx
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// 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 +846,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 +868,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,637 @@
|
||||
//! Moving a file or folder between two locations without moving its content.
|
||||
//!
|
||||
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||
//! and the original is trashed.
|
||||
//!
|
||||
//! On Proton Drive the same move is one metadata change, because My files and
|
||||
//! this computer's device folders all live on the main volume. So a move
|
||||
//! request that names two absolute paths is resolved here, against every
|
||||
//! location this daemon knows, and done as a server-side move. The local side
|
||||
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||
//! folder either renames its copy along or drops it once Drive has it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::state::State;
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||
/// move does not wait it out.
|
||||
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Where one side of a move lives.
|
||||
enum Place {
|
||||
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
|
||||
/// that mount and `rel` is relative to its mountpoint.
|
||||
Mounted { core: Box<Core>, rel: PathBuf },
|
||||
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||
Mirror {
|
||||
folder: StoredSyncFolder,
|
||||
rel: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn mirror_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Place::Mirror { folder, .. } => Some(folder.id),
|
||||
Place::Mounted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
match self {
|
||||
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||
/// folder wins, although folders are not expected to nest.
|
||||
fn mirror_covering(
|
||||
folders: Vec<StoredSyncFolder>,
|
||||
abs: &Path,
|
||||
) -> Option<(StoredSyncFolder, String)> {
|
||||
let folder = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||
let parts = rel
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(part) => part.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((folder, parts.join("/")))
|
||||
}
|
||||
|
||||
/// `name` inside the folder at `parent`, in baseline form.
|
||||
fn join_rel(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||
/// promise that gets a plain rename after one more look.
|
||||
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let cstr = |p: &Path| {
|
||||
CString::new(p.as_os_str().as_bytes())
|
||||
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
};
|
||||
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD,
|
||||
c_from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
c_to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||
/// parent cycle in corrupt data cannot hang the caller.
|
||||
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||
for _ in 0..MAX_TREE_DEPTH {
|
||||
if ino == root {
|
||||
return true;
|
||||
}
|
||||
match st.entries.get(&ino) {
|
||||
Some(entry) if entry.parent != ino => ino = entry.parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// How deep [`ino_is_under`] walks before it gives up.
|
||||
const MAX_TREE_DEPTH: usize = 1024;
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||
/// are absolute local paths and may be in different locations: My files,
|
||||
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||
///
|
||||
/// Within one mount this is [`Core::move_to`]. Across locations it
|
||||
/// is a server-side move followed by local bookkeeping; see the module
|
||||
/// documentation. Anything the move could lose is refused up front.
|
||||
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||
let source = self.locate(src)?;
|
||||
let dest = self.locate(dest_parent)?;
|
||||
if let (
|
||||
Place::Mounted { core, rel },
|
||||
Place::Mounted {
|
||||
core: dest_core,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
&& Arc::ptr_eq(&core.root, &dest_core.root)
|
||||
{
|
||||
return core.move_to(rel, dest_rel);
|
||||
}
|
||||
if source.is_root() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is the root of a synced location and cannot be moved",
|
||||
src.display()
|
||||
)));
|
||||
}
|
||||
if dest_parent.starts_with(src) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
let name = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||
.to_string();
|
||||
|
||||
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||
// sees the move half done and two moves cannot deadlock.
|
||||
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||
let mut guards = Vec::with_capacity(locks.len());
|
||||
for lock in &locks {
|
||||
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
baselines.insert(id, baseline);
|
||||
}
|
||||
|
||||
let src_uid = self.source_uid(&source, &baselines)?;
|
||||
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||
if src_uid.volume_id != dest_uid.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
|
||||
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||
// proven about it. Another filesystem makes that impossible, and the
|
||||
// pair then drops the source copy like any other move out of a mirror.
|
||||
let mut renamed = None;
|
||||
if let (
|
||||
Place::Mirror { folder, rel },
|
||||
Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
{
|
||||
let from = src.to_path_buf();
|
||||
let to = dest_parent.join(&name);
|
||||
match rename_noreplace(&from, &to) {
|
||||
Ok(()) => renamed = Some((from, to)),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||
"move: mirror folders are on different filesystems");
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in {}",
|
||||
dest_parent.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::internal(format!(
|
||||
"could not move {} to {}: {e}",
|
||||
rel,
|
||||
join_rel(dest_rel, &name)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, rel } = &source
|
||||
&& let Some(reason) =
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
|
||||
if let Some((from, to)) = &renamed
|
||||
&& let Err(back) = std::fs::rename(to, from)
|
||||
{
|
||||
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||
"move: could not put the local copy back after the move failed");
|
||||
}
|
||||
return Err(CoreError::from_api(&e, "move"));
|
||||
}
|
||||
|
||||
self.settle_source(
|
||||
&source,
|
||||
&src_uid,
|
||||
renamed.is_some(),
|
||||
&dest,
|
||||
&dest_uid,
|
||||
&name,
|
||||
src,
|
||||
);
|
||||
self.settle_dest(&dest, &dest_uid, &name);
|
||||
drop(guards);
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, .. } = &dest
|
||||
{
|
||||
// Nothing local exists yet; the folder's next pass downloads it.
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||
if !rel
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} escapes its location",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Place::Mounted {
|
||||
core: Box::new(core),
|
||||
rel,
|
||||
});
|
||||
}
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
mirror_covering(folders, abs)
|
||||
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!(
|
||||
"{} is not in a Proton Drive location",
|
||||
abs.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The node being moved, once everything that must hold before it leaves
|
||||
/// its location has been checked.
|
||||
fn source_uid(
|
||||
&self,
|
||||
source: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"it is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||
for authority in [&uid, &old_parent] {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(ino, &uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the node moves into. Refuses one that already holds `name`
|
||||
/// on this disk; Drive refuses a clash on its side by itself.
|
||||
fn dest_uid(
|
||||
&self,
|
||||
dest: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
name: &str,
|
||||
) -> CoreResult<NodeUid> {
|
||||
let uid = match dest {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
let is_folder = core
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
core.require_uid_writable(&uid)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
uid
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
let local = Path::new(&folder.local_path).join(rel);
|
||||
if !local.is_dir() {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in the destination"
|
||||
)));
|
||||
}
|
||||
let uid = if rel.is_empty() {
|
||||
Some(folder.remote_uid.as_str())
|
||||
} else {
|
||||
baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
};
|
||||
uid.and_then(parse_uid).ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"the destination is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||
/// under it, or a file under it is open for writing. Moving such a node away
|
||||
/// would strand the pending change against a path that no longer leads to
|
||||
/// it.
|
||||
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
if ops.iter().any(|op| {
|
||||
under(self, uid, &op.uid)
|
||||
|| op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.is_some_and(|p| under(self, uid, p))
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||
// table. A file opened just after its create landed holds the
|
||||
// placeholder uid until the tree catches up, and the nodes table no
|
||||
// longer has that uid, so the move went ahead under an open write.
|
||||
let mut writing = false;
|
||||
self.for_each_state(|st| {
|
||||
writing = st
|
||||
.active_writes
|
||||
.keys()
|
||||
.any(|&open| ino_is_under(st, open, ino));
|
||||
});
|
||||
Ok(writing)
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn settle_source(
|
||||
&self,
|
||||
source: &Place,
|
||||
uid: &NodeUid,
|
||||
renamed: bool,
|
||||
dest: &Place,
|
||||
dest_uid: &NodeUid,
|
||||
name: &str,
|
||||
src: &Path,
|
||||
) {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
// Moved everywhere, so no mount keeps serving it under the old
|
||||
// path and its row stays (`docs/BUGS.md` B117). The content
|
||||
// cache is keyed by uid and stays valid.
|
||||
self.relocate_everywhere(uid, dest_uid, name);
|
||||
core.invalidate_parent_listing(rel);
|
||||
}
|
||||
Place::Mirror { folder, rel } if renamed => {
|
||||
let Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
} = dest
|
||||
else {
|
||||
unreachable!("a local rename needs a mirror destination");
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_move(
|
||||
folder.id,
|
||||
rel,
|
||||
dest_folder.id,
|
||||
&join_rel(dest_rel, name),
|
||||
) {
|
||||
// The next passes see a copy with no baseline on one side
|
||||
// and a baseline with no copy on the other. That uploads
|
||||
// the files again and trashes the moved node, but loses
|
||||
// nothing.
|
||||
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||
}
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
// Drive had all of it when the move was checked, so the local
|
||||
// copy is only a stale duplicate now. Something written into it
|
||||
// since would go with it, so look again first; if anything is
|
||||
// new, the copy stays and the next pass uploads it as new.
|
||||
let changed = match self.db.sync_entries(folder.id) {
|
||||
Ok(baseline) => {
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||
}
|
||||
Err(e) => Some(format!("db: {e}")),
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||
}
|
||||
if let Some(reason) = changed {
|
||||
warn!(path = %src.display(), reason,
|
||||
"move: the local copy changed during the move; keeping it");
|
||||
return;
|
||||
}
|
||||
let removed = if src.is_dir() {
|
||||
std::fs::remove_dir_all(src)
|
||||
} else {
|
||||
std::fs::remove_file(src)
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
warn!(path = %src.display(), error = %e,
|
||||
"move: could not remove the local copy after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the destination location level with a move Drive has made.
|
||||
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||
if let Place::Mounted { .. } = dest {
|
||||
self.invalidate_children_of(uid);
|
||||
// A lookup that missed before the move may be cached as absent.
|
||||
self.for_each_mount(|st, notify| {
|
||||
if let Some(&ino) = st.by_uid.get(uid) {
|
||||
notify.inval_entry(ino, name.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: format!("vol~root{id}"),
|
||||
remote_share_id: String::new(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||
assert_eq!(found.id, 1);
|
||||
assert_eq!(rel, "a/b.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||
assert_eq!(rel, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_prefers_the_deepest_folder() {
|
||||
let folders = vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||
];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||
assert_eq!(found.id, 2);
|
||||
assert_eq!(rel, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||
let folders = || {
|
||||
vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Media", "ondemand"),
|
||||
]
|
||||
};
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||
std::fs::write(&a, b"moved").unwrap();
|
||||
std::fs::write(&b, b"kept").unwrap();
|
||||
|
||||
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||
assert!(a.exists());
|
||||
|
||||
rename_noreplace(&a, &c).unwrap();
|
||||
assert!(!a.exists());
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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`].
|
||||
///
|
||||
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
size,
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
@@ -75,6 +76,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +465,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 +483,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 +511,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 +612,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 +634,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,6 +661,12 @@ impl Core {
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
///
|
||||
/// A share with no member, no invitation and no link is left out: it is
|
||||
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||
/// round to removing it, minutes later. A share whose details could not all
|
||||
/// be read stays listed, so a failed lookup never hides a live share.
|
||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||
let uids = self
|
||||
.rt
|
||||
@@ -658,7 +677,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 {
|
||||
@@ -666,24 +685,26 @@ impl Core {
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
.map(|m| m.len());
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
.map(|i| i.len());
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(public_link_info);
|
||||
.map(|i| i.len());
|
||||
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||
(&members, &proton_invites, &external_invites, &link)
|
||||
{
|
||||
debug!(%uid, "shared by me: skipping an emptied share");
|
||||
continue;
|
||||
}
|
||||
let members = members.unwrap_or(0);
|
||||
let proton_invites = proton_invites.unwrap_or(0);
|
||||
let external_invites = external_invites.unwrap_or(0);
|
||||
let link = link.ok().flatten().map(public_link_info);
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
@@ -703,19 +724,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 +772,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);
|
||||
});
|
||||
|
||||
+845
-129
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||
// path can be resolved back to the folder it belongs to.
|
||||
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
|
||||
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let tx_events = core.sync_tx.clone();
|
||||
let watched_events = watched.clone();
|
||||
let mut watcher =
|
||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||
/// out of date), falls back to every folder.
|
||||
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||
let mut ids = paths.iter().map(|path| {
|
||||
watched
|
||||
.iter()
|
||||
.filter(|(root, _)| path.starts_with(root))
|
||||
// The deepest root, should one mirror folder sit inside another.
|
||||
.max_by_key(|(root, _)| root.components().count())
|
||||
.map(|(_, id)| *id)
|
||||
});
|
||||
match ids.next() {
|
||||
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||
_ => SyncMsg::ReconcileAll,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||
/// watch newly-added folders, drop removed ones.
|
||||
///
|
||||
/// The watch calls run on a copy, outside the lock: the watcher's event
|
||||
/// callback takes the same lock, and a backend that hands a watch request to
|
||||
/// its event thread and waits for the answer would deadlock against it.
|
||||
fn rewatch(
|
||||
core: &Core,
|
||||
watcher: &mut notify::RecommendedWatcher,
|
||||
@@ -274,7 +299,7 @@ fn rewatch(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock();
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
@@ -294,6 +319,7 @@ fn rewatch(
|
||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
@@ -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;
|
||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
||||
pub(super) errors: usize,
|
||||
/// Files skipped because another process held them open for writing.
|
||||
pub(super) deferred: usize,
|
||||
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||
/// the set changes, rather than on every poll.
|
||||
pub(super) unscanned: Vec<String>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
@@ -108,6 +113,7 @@ impl Outcome {
|
||||
Applied::Uploaded => self.uploaded += 1,
|
||||
Applied::Downloaded => self.downloaded += 1,
|
||||
Applied::Conflict => self.conflicts += 1,
|
||||
Applied::Vanished => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +166,7 @@ pub(super) enum Pending {
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
/// 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 +232,10 @@ pub(super) enum PassAbort {
|
||||
Interrupted,
|
||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||
Failed(String),
|
||||
/// The daemon is stopping. What the pass sent before the stop has landed or
|
||||
/// will be cancelled with the runtime; nothing more is sent, and the next
|
||||
/// start's pass picks up from the baseline.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl From<String> for PassAbort {
|
||||
|
||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The paths a local scan left out because it could not read them: a folder
|
||||
/// it has no permission for, an entry that vanished between the listing and
|
||||
/// its `stat`, a name that is not UTF-8.
|
||||
///
|
||||
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||
/// covers from classification, exactly as it drops ignored paths, so a
|
||||
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||
/// stopping every other file in the tree from syncing (B196).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Unscanned {
|
||||
covered: HashSet<String>,
|
||||
/// The subset worth telling the user about, with why: an entry that only
|
||||
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||
unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
impl Unscanned {
|
||||
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||
pub(super) fn vanished(&mut self, rel: String) {
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Leave out `rel`, which cannot be read for `reason`.
|
||||
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||
self.unreadable.push(format!("{rel} ({reason})"));
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Whether `rel` is a left-out path or lies under one.
|
||||
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||
if self.covered.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut at = rel;
|
||||
loop {
|
||||
if self.covered.contains(at) {
|
||||
return true;
|
||||
}
|
||||
match at.rfind('/') {
|
||||
Some(i) => at = &at[..i],
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||
pub(super) fn report(&self) -> Vec<String> {
|
||||
let mut lines = self.unreadable.clone();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||
/// checks and the set a push pass reads deletions from, since a path outside
|
||||
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||
pub(super) fn tracked_baseline<'a, B>(
|
||||
baseline: &'a HashMap<String, B>,
|
||||
rules: &IgnoreRules,
|
||||
unscanned: &Unscanned,
|
||||
) -> HashMap<String, &'a B> {
|
||||
let mut tracked = filter_baseline(baseline, rules);
|
||||
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||
tracked
|
||||
}
|
||||
|
||||
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||
/// children with it.
|
||||
///
|
||||
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||
/// it raw trashed every synced path that later became ignored, because the walk
|
||||
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||
/// deleted the local copy too (B197).
|
||||
pub(super) fn local_deletions<B, L>(
|
||||
tracked: &HashMap<String, &B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Vec<String> {
|
||||
let mut missing: Vec<String> = tracked
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
missing
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||
///
|
||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||
@@ -218,7 +308,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 +381,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 {
|
||||
|
||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
||||
detail: Mutex<String>,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
/// Housekeeping that moves nothing; see [`JobItem::background`].
|
||||
background: bool,
|
||||
}
|
||||
|
||||
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||
@@ -160,12 +162,25 @@ impl TransferRegistry {
|
||||
/// that carries its progress and deregisters the job when dropped. The job
|
||||
/// starts indeterminate; give it a `total` once one is known.
|
||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), false)
|
||||
}
|
||||
|
||||
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
|
||||
/// for and that changes nothing in their files, such as an index rebuild or
|
||||
/// a cache check. It is reported, but front ends don't call the drive busy
|
||||
/// over it.
|
||||
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), true)
|
||||
}
|
||||
|
||||
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = Arc::new(JobEntry {
|
||||
title: title.into(),
|
||||
title,
|
||||
detail: Mutex::new(String::new()),
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
background,
|
||||
});
|
||||
self.jobs.lock().insert(id, entry.clone());
|
||||
JobGuard {
|
||||
@@ -191,6 +206,7 @@ impl TransferRegistry {
|
||||
detail: e.detail.lock().clone(),
|
||||
done: e.done.load(Ordering::Relaxed),
|
||||
total: e.total.load(Ordering::Relaxed),
|
||||
background: e.background,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 441 B |
@@ -14,7 +14,6 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
.sidebar-status image.warning { color: var(--warning-color); }
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
/* The account row is the main menu's button: a row, not a pushbutton, so
|
||||
* the name keeps the text weight. */
|
||||
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
|
||||
/* A count of what waits on a page, at the end of its sidebar row */
|
||||
.sidebar-badge {
|
||||
min-width: 10px;
|
||||
@@ -46,7 +48,11 @@
|
||||
.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); }
|
||||
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
||||
* padding, so the "›" before it is spaced like the ones between buttons. */
|
||||
.crumb-current { padding: 0 10px; }
|
||||
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); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
|
||||
@@ -34,6 +34,7 @@ use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||
use widgets::compat::*;
|
||||
use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
@@ -75,8 +76,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 +120,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>,
|
||||
@@ -199,12 +203,20 @@ impl Ui {
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
);
|
||||
// The verification window's stdout carries its token back to the app.
|
||||
if verify {
|
||||
logs.with_writer(std::io::stderr).init();
|
||||
} else {
|
||||
logs.init();
|
||||
}
|
||||
i18n::init();
|
||||
if verify {
|
||||
return run_verification_window();
|
||||
}
|
||||
|
||||
// The tray launches the app with arguments ("--page locations",
|
||||
// "--confirm-stop"), which a running instance must receive too.
|
||||
@@ -300,6 +312,11 @@ fn build_window(app: &adw::Application) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// On a fresh install the daemon has not run yet to make these, and the
|
||||
// Takeout page keeps its staged archives in the state dir (B194).
|
||||
if let Err(e) = dirs.ensure() {
|
||||
tracing::warn!("cannot create the app's directories: {e}");
|
||||
}
|
||||
|
||||
let stack = adw::ViewStack::new();
|
||||
let (login_page, login_widgets) = build_login_page();
|
||||
@@ -362,6 +379,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(),
|
||||
@@ -520,6 +538,7 @@ fn build_window(app: &adw::Application) {
|
||||
reload_pending: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
has_more: Cell::new(false),
|
||||
library: Cell::new(true),
|
||||
page_waiters: RefCell::new(Vec::new()),
|
||||
width: Cell::new(0),
|
||||
photo_tex: RefCell::new(HashMap::new()),
|
||||
@@ -561,6 +580,8 @@ fn build_window(app: &adw::Application) {
|
||||
detail: locations_widgets.card_detail.clone(),
|
||||
pause: locations_widgets.pause.clone(),
|
||||
paused: Cell::new(false),
|
||||
sign_in: locations_widgets.card_sign_in.clone(),
|
||||
restart: locations_widgets.card_restart.clone(),
|
||||
},
|
||||
queue: QueueState {
|
||||
group: locations_widgets.queue_group.clone(),
|
||||
@@ -668,6 +689,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.
|
||||
@@ -804,7 +834,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&brand));
|
||||
header.pack_end(&build_primary_menu());
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
@@ -1127,25 +1156,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
||||
/// belong on any one page.
|
||||
fn build_primary_menu() -> gtk4::MenuButton {
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
menu.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
gtk4::MenuButton::builder()
|
||||
.icon_name("pdfs-menu-symbolic")
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.primary(true)
|
||||
.menu_model(&menu)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Back the primary and account menus' entries with window actions.
|
||||
/// Back the main menu's entries with window actions.
|
||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let preferences = gio::SimpleAction::new("preferences", None);
|
||||
let ui_prefs = ui.clone();
|
||||
@@ -1197,9 +1208,9 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let dialog = adw::AboutDialog::builder()
|
||||
.application_name(gettext("Proton Drive for Linux"))
|
||||
.application_icon("io.narl.proton-drive-linux")
|
||||
.version(pdfs_core::config::APP_VERSION)
|
||||
.version(env!("CARGO_PKG_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)
|
||||
@@ -1282,6 +1293,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
||||
if ui_page.session.borrow().is_none() {
|
||||
return;
|
||||
}
|
||||
// Signed in, but Proton refused the session (the tray's "Sign In
|
||||
// Again…"): the login page, with the rest of the app still reachable.
|
||||
if page == "login" {
|
||||
show_login(&ui_page);
|
||||
return;
|
||||
}
|
||||
// Activity lives on as the Sync page's History view.
|
||||
if page == "activity" {
|
||||
ui_page.locations.views.set_visible_child_name("history");
|
||||
@@ -1337,8 +1354,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
format!(
|
||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||
pdfs_core::config::APP_VERSION,
|
||||
pdfs_core::config::USER_AGENT,
|
||||
config.resolved_app_version(),
|
||||
config.resolved_user_agent(),
|
||||
adw::major_version(),
|
||||
adw::minor_version(),
|
||||
adw::micro_version(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Reflect the current selection in the bulk bar: how many are selected, and
|
||||
/// which bulk actions apply to that mix. Offline state is a file-only notion, so
|
||||
/// a folder anywhere in the selection disables the two pin buttons rather than
|
||||
/// half-failing once pressed.
|
||||
/// which bulk actions apply to that mix. An entry kept offline through a pinned
|
||||
/// folder above it cannot be made online only on its own, so it does not count
|
||||
/// towards Make online only.
|
||||
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
let entries = selected_entries(ui);
|
||||
// One selected entry is the details pane's job; the bar is for a batch.
|
||||
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
ui.browser.bulk.set_reveal_child(false);
|
||||
return;
|
||||
}
|
||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
|
||||
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
||||
let mounted = *ui.mounted.borrow();
|
||||
ui.browser.bulk_label.set_label(&ngettext_f(
|
||||
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
&[],
|
||||
));
|
||||
ui.browser.bulk_trash.set_sensitive(mounted);
|
||||
ui.browser
|
||||
.bulk_pin
|
||||
.set_sensitive(mounted && files_only && any_unpinned);
|
||||
ui.browser
|
||||
.bulk_unpin
|
||||
.set_sensitive(mounted && files_only && any_pinned);
|
||||
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
|
||||
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
|
||||
ui.browser.bulk.set_reveal_child(true);
|
||||
}
|
||||
|
||||
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin or unpin every selected file, one request at a time.
|
||||
/// Pin or unpin every selected entry, one request at a time. A folder's files
|
||||
/// download in the background, so its request returns before they are there.
|
||||
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
let socket = ui.dirs.control_socket();
|
||||
// Only the entries that need changing: re-pinning an already-pinned file is
|
||||
// a wasted round-trip, and it would inflate the count the toast reports.
|
||||
let paths: Vec<String> = entries
|
||||
// a wasted round-trip, and it would inflate the count the toast reports. One
|
||||
// kept through a pinned folder above it has no pin of its own to remove.
|
||||
let changing: Vec<&DirEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.pinned != pin)
|
||||
.map(|e| entry_rel(ui, e))
|
||||
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
|
||||
.collect();
|
||||
let any_folder = changing.iter().any(|e| e.is_dir);
|
||||
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
(0, None) => {}
|
||||
(n, _) => toast(
|
||||
&ui,
|
||||
&if pin {
|
||||
&if pin && any_folder {
|
||||
ngettext_f(
|
||||
"Making {n} item available offline…",
|
||||
"Making {n} items available offline…",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if pin {
|
||||
ngettext_f(
|
||||
"{n} file is now available offline",
|
||||
"{n} files are now available offline",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if any_folder {
|
||||
ngettext_f(
|
||||
"{n} item is now online only",
|
||||
"{n} items are now online only",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
ngettext_f(
|
||||
"{n} file is now online only",
|
||||
@@ -832,8 +844,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());
|
||||
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
}
|
||||
menu.section();
|
||||
|
||||
if !entry.is_dir {
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove: the check shows that state without offering to change it.
|
||||
if entry.pinned_by_folder {
|
||||
menu.locked_toggle(&gettext("Available offline"), true);
|
||||
} else {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
||||
toggle_pin(&ui_c, &entry_c)
|
||||
});
|
||||
menu.section();
|
||||
}
|
||||
menu.section();
|
||||
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Share…"), move || {
|
||||
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
/// simply absent here rather than offered and then refused.
|
||||
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
// Offline state is a file-only notion; the check shows whether the whole
|
||||
// batch already is.
|
||||
if entries.iter().all(|e| !e.is_dir) {
|
||||
// The check shows whether the whole batch already is offline.
|
||||
{
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
||||
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let name = entry.name.clone();
|
||||
let pinned = entry.pinned;
|
||||
let is_dir = entry.is_dir;
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
&ui,
|
||||
&if pinned {
|
||||
gettext_f("“{name}” is now online only", &[("name", &name)])
|
||||
} else if is_dir {
|
||||
// The daemon downloads a folder's files in the background.
|
||||
gettext_f("Making “{name}” available offline…", &[("name", &name)])
|
||||
} else {
|
||||
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
||||
},
|
||||
@@ -1220,9 +1239,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
));
|
||||
let mut acc = String::new();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
ui.browser.crumb.append(&sep);
|
||||
ui.browser.crumb.append(&crumb_separator());
|
||||
acc = if acc.is_empty() {
|
||||
seg.to_string()
|
||||
} else {
|
||||
@@ -1237,12 +1254,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
/// flat button that navigates to `target` (clearing any active search first).
|
||||
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -1920,6 +1932,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 +1983,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 +1991,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 +2088,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 +2173,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 {
|
||||
@@ -2328,12 +2506,19 @@ 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
|
||||
/// cached (downloaded, evictable); everything else is online-only.
|
||||
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
|
||||
/// cache state of its own, so it has one only when kept offline. 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;
|
||||
return entry
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if entry.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
@@ -2347,9 +2532,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));
|
||||
@@ -2740,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
size: h.size,
|
||||
modified: h.modified,
|
||||
pinned: h.pinned,
|
||||
pinned_by_folder: false,
|
||||
cached: h.cached,
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
@@ -2749,6 +2937,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 +2975,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 {
|
||||
@@ -2799,6 +2988,7 @@ mod tests {
|
||||
size,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: String::new(),
|
||||
path: String::new(),
|
||||
@@ -2808,6 +2998,7 @@ mod tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2815,6 +3006,22 @@ 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.pinned = true;
|
||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
|
||||
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 +3087,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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Move to This Computer…"), move || {
|
||||
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` levels below the device list.
|
||||
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask before moving another computer's folders into this computer's backup,
|
||||
/// then offer them for syncing here.
|
||||
///
|
||||
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||
/// loses its folders, and a copy of Proton Drive still running there would
|
||||
/// watch them vanish.
|
||||
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Move Backup to This Computer?"))
|
||||
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||
.body(gettext_f(
|
||||
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the computer whose folders moved to this one.
|
||||
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "move" {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::MigrateDevice { uid: uid.clone() },
|
||||
);
|
||||
let (ui, done) = (ui.clone(), done.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
let failed = gettext("Couldn't move that backup");
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
// The folders are this computer's now, so its own restore
|
||||
// picker is where the user chooses their local paths.
|
||||
prompt_restore_folders(&ui, None);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
|
||||
@@ -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
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct SyncCard {
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
/// Whether the last status said paused, so the button knows which way to go.
|
||||
pub(crate) paused: Cell<bool>,
|
||||
/// Shown only while Proton refuses the session; opens the sign-in page.
|
||||
pub(crate) sign_in: gtk4::Button,
|
||||
/// Shown only while the daemon is older than the app; restarts it.
|
||||
pub(crate) restart: gtk4::Button,
|
||||
}
|
||||
|
||||
/// What a folder row's controls are built from. The subtitle and progress bar
|
||||
@@ -70,8 +74,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 {
|
||||
@@ -107,6 +112,8 @@ pub(crate) struct LocationsWidgets {
|
||||
pub(crate) card_icon: gtk4::Image,
|
||||
pub(crate) card_title: gtk4::Label,
|
||||
pub(crate) card_detail: gtk4::Label,
|
||||
pub(crate) card_sign_in: gtk4::Button,
|
||||
pub(crate) card_restart: gtk4::Button,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
@@ -133,7 +140,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_title, card_detail) = build_sync_card();
|
||||
let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
|
||||
let pause = build_pause_button();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
@@ -252,6 +259,8 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
card_icon,
|
||||
card_title,
|
||||
card_detail,
|
||||
card_sign_in,
|
||||
card_restart,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
@@ -262,20 +271,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()
|
||||
}
|
||||
|
||||
@@ -287,6 +298,16 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
wire_pause(ui);
|
||||
let ui_sign_in = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.sign_in
|
||||
.connect_clicked(move |_| show_login(&ui_sign_in));
|
||||
let ui_restart = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.restart
|
||||
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
|
||||
let ui_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
@@ -376,9 +397,16 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
||||
state.banner.set_revealed(away && title.is_some());
|
||||
}
|
||||
|
||||
/// The status hero: a large state icon, the state in words, and what it
|
||||
/// means.
|
||||
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
/// The status hero: a large state icon, the state in words, what it means,
|
||||
/// and the way out when there is one only the user can take.
|
||||
fn build_sync_card() -> (
|
||||
gtk4::Box,
|
||||
gtk4::Image,
|
||||
gtk4::Label,
|
||||
gtk4::Label,
|
||||
gtk4::Button,
|
||||
gtk4::Button,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("pdfs-sync-symbolic")
|
||||
.pixel_size(48)
|
||||
@@ -405,9 +433,25 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("sync-card");
|
||||
let sign_in = gtk4::Button::builder()
|
||||
.label(gettext("Sign In Again"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
sign_in.add_css_class("pill");
|
||||
sign_in.add_css_class("suggested-action");
|
||||
let restart = gtk4::Button::builder()
|
||||
.label(gettext("Restart"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
restart.add_css_class("pill");
|
||||
restart.add_css_class("suggested-action");
|
||||
card.append(&icon);
|
||||
card.append(&text);
|
||||
(card, icon, title, detail)
|
||||
card.append(&sign_in);
|
||||
card.append(&restart);
|
||||
(card, icon, title, detail, sign_in, restart)
|
||||
}
|
||||
|
||||
/// Pause/Resume, with timed pauses in its menu.
|
||||
@@ -589,7 +633,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 +690,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 +708,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 +733,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 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -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
|
||||
@@ -145,15 +150,40 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does.
|
||||
// sign-in, and the worker is blocked on the credential until it does.
|
||||
let ui_hv = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
// Anything but a credential drops the sender, which the worker
|
||||
// reads as a cancelled sign-in.
|
||||
match verify_human(hv.verification_url()).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
// The web view could not start, most likely its sandbox
|
||||
// (B195): the user's browser can show the page instead.
|
||||
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,6 +239,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 +256,34 @@ 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 challenge, and the sender for the
|
||||
/// credential the user earns by solving it.
|
||||
pub(crate) hv: async_channel::Receiver<(
|
||||
HumanVerification,
|
||||
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
)>,
|
||||
/// 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,19 +311,30 @@ pub(crate) fn spawn_login(
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||
},
|
||||
|hv| {
|
||||
// Gated: hand the UI the page to show, block on the token the
|
||||
// user earns by solving it.
|
||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||
hv_req_tx
|
||||
.send_blocking((hv.verification_url(), token_tx))
|
||||
.map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
let token = token_rx
|
||||
|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("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
|
||||
Ok(auth::Mailbox {
|
||||
password: password.into(),
|
||||
remember,
|
||||
})
|
||||
},
|
||||
|hv| {
|
||||
// Gated: hand the UI the challenge, block on the credential
|
||||
// the user earns by solving it.
|
||||
let (credential_tx, credential_rx) =
|
||||
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
credential_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -287,7 +342,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
|
||||
@@ -330,6 +521,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Let the user solve the challenge in their own browser, for when the
|
||||
/// verification window could not come up. The page cannot hand its token back
|
||||
/// to us, so the user says when they are done, and the retry carries the
|
||||
/// challenge token, which the solved page made good (see
|
||||
/// [`auth::browser_verified`]).
|
||||
fn verify_in_browser(
|
||||
ui: &Rc<Ui>,
|
||||
hv: &HumanVerification,
|
||||
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Verify in Your Browser"))
|
||||
.body(gettext(
|
||||
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let url = auth::browser_verification_url(hv);
|
||||
let open = gtk4::Button::builder()
|
||||
.label(gettext("Open Verification Page"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("pill");
|
||||
dialog.set_extra_child(Some(&open));
|
||||
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("continue", &gettext("Continue"));
|
||||
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
|
||||
// Continuing before the page was even opened would only fail the retry.
|
||||
dialog.set_response_enabled("continue", false);
|
||||
dialog.set_default_response(Some("continue"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let dialog_weak = dialog.downgrade();
|
||||
open.connect_clicked(move |_| {
|
||||
open_uri(&url);
|
||||
if let Some(dialog) = dialog_weak.upgrade() {
|
||||
dialog.set_response_enabled("continue", true);
|
||||
}
|
||||
});
|
||||
|
||||
let credential = auth::browser_verified(hv);
|
||||
let credential_tx = RefCell::new(Some(credential_tx));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||
// recv errors out and the login is reported as cancelled.
|
||||
if let Some(tx) = credential_tx.borrow_mut().take()
|
||||
&& resp == "continue"
|
||||
{
|
||||
let _ = tx.send(credential.clone());
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
@@ -374,6 +622,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());
|
||||
|
||||
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Whether the last page came back full, so there may be more to load.
|
||||
pub(crate) has_more: Cell<bool>,
|
||||
/// Whether the account has a photo library. Until the daemon says it has
|
||||
/// none, assume it does, so Upload is not greyed out while the page loads.
|
||||
pub(crate) library: Cell<bool>,
|
||||
/// Run once the page in flight has landed in [`Self::model`] — how the
|
||||
/// lightbox steps past the last loaded photo.
|
||||
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
||||
@@ -3341,6 +3344,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
items,
|
||||
counts,
|
||||
})) => {
|
||||
// Nothing can go into a library the account does not have; the
|
||||
// SDK refuses the upload before sending it (issue #38).
|
||||
ui.gallery.library.set(available);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(available && *ui.mounted.borrow());
|
||||
if !available {
|
||||
gallery_status(
|
||||
&ui,
|
||||
|
||||
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` folders below the top level.
|
||||
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
|
||||
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
let done = done.clone();
|
||||
let stopped = uids.clone();
|
||||
run_requests(
|
||||
&ui_c,
|
||||
uids.iter()
|
||||
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
.collect(),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
move |ui| {
|
||||
forget_shared_by_me(ui, &stopped);
|
||||
reload_listing(ui);
|
||||
toast(ui, &done);
|
||||
},
|
||||
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Take items whose sharing just stopped off the page now, rather than when
|
||||
/// the reload that confirms it comes back: that reload asks after every share
|
||||
/// in turn and takes a while with many of them.
|
||||
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
|
||||
let items = {
|
||||
let mut items = ui.shared_by_me.items.borrow_mut();
|
||||
items.retain(|item| !uids.contains(&item.uid));
|
||||
items.clone()
|
||||
};
|
||||
repaint_shared_by_me(ui, &items);
|
||||
}
|
||||
|
||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
@@ -608,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
size: 0,
|
||||
modified: item.modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
@@ -618,6 +633,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
// The app's one menu, on the account row the way Proton's own apps keep it:
|
||||
// app-wide entries first, the account's last. The whole row opens it, and
|
||||
// F10 too, as the primary menu.
|
||||
let app_section = gio::Menu::new();
|
||||
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
app_section.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
let account_section = gio::Menu::new();
|
||||
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
let account_menu = gio::Menu::new();
|
||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
let sign_out = gio::Menu::new();
|
||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
account_menu.append_section(None, &sign_out);
|
||||
let account_button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text(gettext("Account"))
|
||||
account_menu.append_section(None, &app_section);
|
||||
account_menu.append_section(None, &account_section);
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
row.append(&avatar);
|
||||
row.append(&account_name);
|
||||
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||
let account = gtk4::MenuButton::builder()
|
||||
.child(&row)
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.menu_model(&account_menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.direction(gtk4::ArrowType::Up)
|
||||
.primary(true)
|
||||
.build();
|
||||
account_button.add_css_class("flat");
|
||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
account.add_css_class("flat");
|
||||
account.add_css_class("sidebar-account");
|
||||
account.append(&avatar);
|
||||
account.append(&account_name);
|
||||
account.append(&account_button);
|
||||
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
footer.add_css_class("sidebar-footer");
|
||||
@@ -825,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
||||
}
|
||||
|
||||
/// Show the sign-in page while a session is still stored, because Proton has
|
||||
/// refused it. The sidebar stays, so the user can go back to cached files; a
|
||||
/// login moves on to the files as from any sign-in ([`refresh`]).
|
||||
pub(crate) fn show_login(ui: &Rc<Ui>) {
|
||||
ui.stack.set_visible_child_name("login");
|
||||
}
|
||||
|
||||
/// Repaint the window from the cached login identity, then ask the daemon for
|
||||
/// its status and transfers. Runs at startup, after a login and after a service
|
||||
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||
@@ -860,6 +878,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
offer_unlock(ui);
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
}
|
||||
@@ -939,7 +958,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
*ui.mounted.borrow_mut() = mounted;
|
||||
sync_mounted_actions(ui, mounted);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(mounted && ui.gallery.library.get());
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
ui.details.details.trash_button.set_sensitive(mounted);
|
||||
@@ -1069,7 +1090,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);
|
||||
@@ -1205,6 +1230,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1215,7 +1243,13 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
let state = if session_expired {
|
||||
SyncState::SessionExpired { queued }
|
||||
} else if tokens_unsaved {
|
||||
SyncState::TokensUnsaved
|
||||
} else if pdfs_core::control::daemon_outdated(&version) {
|
||||
SyncState::Outdated { running: version }
|
||||
} else if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
@@ -1278,6 +1312,20 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// Proton refused the session: nothing reaches it until the user signs in
|
||||
/// again. Outranks a pause, which it makes moot.
|
||||
SessionExpired {
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
||||
/// a reboot before the keyring takes them signs the user out.
|
||||
TokensUnsaved,
|
||||
/// The daemon is an older release than this window: an upgrade replaced
|
||||
/// the files but left the service running, with the old release's bugs,
|
||||
/// until it restarts (#33). `running` is empty when it is too old to say.
|
||||
Outdated {
|
||||
running: String,
|
||||
},
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
@@ -1312,7 +1360,44 @@ pub(crate) enum SyncState {
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::SessionExpired { queued } => (
|
||||
"dialog-password-symbolic",
|
||||
Some("error"),
|
||||
gettext("Session expired"),
|
||||
Some(match queued {
|
||||
// Translators: {queued} says what is waiting, such as "3 uploads
|
||||
// queued".
|
||||
Some(queued) => {
|
||||
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
|
||||
}
|
||||
None => gettext("Sign in again to sync"),
|
||||
}),
|
||||
),
|
||||
SyncState::TokensUnsaved => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Sign-in not saved"),
|
||||
Some(gettext(
|
||||
"Unlock your keyring, or the next reboot signs you out",
|
||||
)),
|
||||
),
|
||||
SyncState::Outdated { running } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Restart to finish updating"),
|
||||
Some(if running.is_empty() {
|
||||
gettext("An older version of Proton Drive is still running")
|
||||
} else {
|
||||
// Translators: {version} is a release number such as "3.2.0".
|
||||
gettext_f(
|
||||
"Proton Drive {version} is still running",
|
||||
&[("version", &running)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
@@ -1383,6 +1468,8 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
if let Some(class) = class {
|
||||
image.add_css_class(class);
|
||||
}
|
||||
ui.locations.card.sign_in.set_visible(expired);
|
||||
ui.locations.card.restart.set_visible(outdated);
|
||||
ui.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
@@ -1451,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("No files kept offline"))
|
||||
.subtitle(gettext(
|
||||
"Right-click a file and choose “Make available offline”.",
|
||||
"Right-click a file or folder and choose “Available offline”.",
|
||||
))
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
|
||||
@@ -524,6 +524,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: name.into(),
|
||||
path: String::new(),
|
||||
@@ -533,6 +534,7 @@ mod tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at,
|
||||
trashed_from: from.map(str::to_string),
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The human-verification (CAPTCHA) dialog.
|
||||
//! The human-verification (CAPTCHA) window.
|
||||
//!
|
||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||
@@ -8,10 +8,26 @@
|
||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||
//! browser because it reports completion by posting a message to its host, not
|
||||
//! by redirecting: a browser has nowhere to post it back to.
|
||||
//!
|
||||
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||
//! on hardened systems that keep bubblewrap from the journal socket
|
||||
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||
//! the token comes back on stdout.
|
||||
|
||||
use crate::*;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use webkit6::prelude::*;
|
||||
|
||||
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||
|
||||
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||
/// WebKit write debug output there.
|
||||
const TOKEN_PREFIX: &str = "hv-token ";
|
||||
|
||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||
///
|
||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||
///
|
||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||
pub(crate) fn prompt_human_verification(
|
||||
ui: &Rc<Ui>,
|
||||
url: &str,
|
||||
token_tx: std::sync::mpsc::Sender<String>,
|
||||
) {
|
||||
/// How the verification window ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Verification {
|
||||
Solved(String),
|
||||
/// Closed without solving.
|
||||
Cancelled,
|
||||
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Show the challenge in the verification window and wait until it closes.
|
||||
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||
Ok(Ok(verification)) => verification,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("cannot run the verification window: {e}");
|
||||
Verification::Failed
|
||||
}
|
||||
Err(_) => Verification::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||
let mut child = Command::new(own_exe())
|
||||
.arg(VERIFY_ARG)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// A failed write means the child is already gone; its exit status says
|
||||
// how. Dropping `stdin` closes it.
|
||||
let _ = writeln!(stdin, "{url}");
|
||||
}
|
||||
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||
// inherit the pipe and may hold it open a moment after the child quits.
|
||||
let token = child
|
||||
.stdout
|
||||
.take()
|
||||
.and_then(|out| find_token(BufReader::new(out)));
|
||||
let status = child.wait()?;
|
||||
Ok(outcome(token, status))
|
||||
}
|
||||
|
||||
/// This very binary, so a development build opens its own window. After an
|
||||
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||
/// installed `pdfs-app` is the next best.
|
||||
fn own_exe() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||
}
|
||||
|
||||
fn find_token(out: impl BufRead) -> Option<String> {
|
||||
out.lines()
|
||||
.map_while(Result::ok)
|
||||
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||
}
|
||||
|
||||
/// A token wins over the exit status: the user solved the page, even if
|
||||
/// WebKit's teardown crashed after it.
|
||||
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||
match token {
|
||||
Some(token) => Verification::Solved(token),
|
||||
None if status.success() => Verification::Cancelled,
|
||||
None => {
|
||||
tracing::error!(%status, "the verification window failed");
|
||||
Verification::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||
/// print the token once the user has solved it. Closing the window exits
|
||||
/// without a token, which the app reads as a cancelled sign-in.
|
||||
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||
let mut url = String::new();
|
||||
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
let url = url.trim().to_owned();
|
||||
|
||||
// Not unique: the app that started it already holds APP_ID.
|
||||
let app = adw::Application::builder()
|
||||
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||
.build();
|
||||
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||
let argv0 = std::env::args().next().unwrap_or_default();
|
||||
app.run_with_args(&[argv0])
|
||||
}
|
||||
|
||||
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||
let content = webkit6::UserContentManager::new();
|
||||
content.add_script(&webkit6::UserScript::new(
|
||||
BRIDGE,
|
||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&adw::HeaderBar::new());
|
||||
body.append(&webview);
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.icon_name(APP_ID)
|
||||
.default_width(420)
|
||||
.default_height(560)
|
||||
.content(&body)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&header);
|
||||
body.append(&webview);
|
||||
dialog.set_child(Some(&body));
|
||||
|
||||
// Taken on the first success *or* on close, so the token is sent at most
|
||||
// once and a cancelled dialog always drops the sender.
|
||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||
|
||||
let tx = token_tx.clone();
|
||||
let dlg_weak = dialog.downgrade();
|
||||
let app = app.downgrade();
|
||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||
let Some(token) = extract_token(&value.to_str()) else {
|
||||
// Every `postMessage` on the page reaches here, most of them the
|
||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
||||
// is simply not ours.
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
let _ = tx.send(token);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||
tracing::error!("cannot hand the verification token back: {e}");
|
||||
}
|
||||
if let Some(dlg) = dlg_weak.upgrade() {
|
||||
dlg.close();
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Covers the close button, Escape, and the user giving up.
|
||||
dialog.connect_closed(move |_| {
|
||||
token_tx.borrow_mut().take();
|
||||
});
|
||||
|
||||
webview.load_uri(url);
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
// Returns immediately; the login worker stays blocked on the channel until
|
||||
// the handler above sends, or the dialog closes and drops the sender.
|
||||
dialog.present(parent.as_ref());
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Pull the verification token out of a message posted by the verification page.
|
||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_token;
|
||||
use super::{Verification, extract_token, find_token, outcome};
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// The wait status of a process killed by SIGABRT.
|
||||
const ABORTED: i32 = 6;
|
||||
|
||||
#[test]
|
||||
fn a_completion_message_yields_its_token() {
|
||||
@@ -163,4 +256,39 @@ mod tests {
|
||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_found_among_other_output() {
|
||||
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closed_without_a_token_cancels() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(0)),
|
||||
Verification::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||
/// failure the app explains, not as the user giving up.
|
||||
#[test]
|
||||
fn a_window_that_died_fails() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Failed
|
||||
);
|
||||
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||
assert_eq!(
|
||||
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Solved("tok-1".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::*;
|
||||
|
||||
/// The "›" between two breadcrumb segments.
|
||||
pub(crate) fn crumb_separator() -> gtk4::Label {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
sep
|
||||
}
|
||||
|
||||
/// The last breadcrumb segment, the folder on screen: a heading rather than a
|
||||
/// button. It carries a flat button's side padding (`.crumb-current`), so the
|
||||
/// "›" before it sits as far from it as from the button before.
|
||||
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
l.add_css_class("crumb-current");
|
||||
l.upcast()
|
||||
}
|
||||
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
parent.to_string()
|
||||
});
|
||||
// Pinning is per file: the context menu and the selection bar offer it for
|
||||
// files only, so the pane must not offer it for folders either.
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove, so the switch shows that state without offering to change it.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
|
||||
gettext("Kept on this computer because a folder it is in is available offline.")
|
||||
} else if entry.is_dir {
|
||||
gettext(
|
||||
"Keep a copy of everything in this folder on this computer, even without a connection.",
|
||||
)
|
||||
} else {
|
||||
gettext("Keep a copy on this computer, even without a connection.")
|
||||
});
|
||||
d.pin_row
|
||||
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
|
||||
d.open_button.set_label(&if entry.is_dir {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
|
||||
@@ -132,8 +132,13 @@ impl FileList {
|
||||
// and the grid collapses to a single column. Allowing a mid-word
|
||||
// break is what keeps the two-line-then-ellipsis budget below
|
||||
// enforceable for *every* name rather than only the ones that
|
||||
// happen to have spaces.
|
||||
// happen to have spaces. Pango marks such a break with a hyphen
|
||||
// that is not in the name ("SteamSetup.e-xe"); a file name is
|
||||
// not prose, so break it bare, as Nautilus does.
|
||||
let no_hyphens = gtk4::pango::AttrList::new();
|
||||
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
|
||||
let label = gtk4::Label::builder()
|
||||
.attributes(&no_hyphens)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(13)
|
||||
|
||||
@@ -61,6 +61,18 @@ impl ActionMenu {
|
||||
self
|
||||
}
|
||||
|
||||
/// A greyed-out check item showing `active`, for a state the user cannot
|
||||
/// change from here.
|
||||
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||
action.set_enabled(false);
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section; the next item starts a new one below a
|
||||
/// separator. An empty section is skipped, so callers can end a group of
|
||||
/// conditional items unconditionally.
|
||||
@@ -128,20 +140,34 @@ impl ActionMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
||||
/// selectors reach through a popover to its parent, and themes style the
|
||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
||||
/// margin around every section. Under the view itself it is no item's child.
|
||||
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||
///
|
||||
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||
/// its parent, and themes style the insides of list items (`gridview > child
|
||||
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||
/// rules up and puts a wide margin around every section. So the search starts
|
||||
/// at the outermost grid or list view around `anchor`.
|
||||
///
|
||||
/// Not on a widget without a layout manager either: a popover is sized and
|
||||
/// placed again when its parent's layout manager allocates it. The list views
|
||||
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
|
||||
/// first given, often too small, and opened scrolling. The host is the first
|
||||
/// widget from there up that has one.
|
||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
let mut host = anchor.clone();
|
||||
let mut start = anchor.clone();
|
||||
let mut widget = anchor.parent();
|
||||
while let Some(w) = widget {
|
||||
widget = w.parent();
|
||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||
host = w;
|
||||
start = w;
|
||||
}
|
||||
}
|
||||
host
|
||||
let mut host = Some(start.clone());
|
||||
while let Some(w) = host {
|
||||
if w.layout_manager().is_some() {
|
||||
return w;
|
||||
}
|
||||
host = w.parent();
|
||||
}
|
||||
start
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod compat;
|
||||
pub(crate) mod crumb;
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
|
||||
@@ -127,13 +127,33 @@ impl Drop for Busy {
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's action, such as Send Invitations. It goes under the section's
|
||||
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
|
||||
/// list reads as a setting, and a button alone in one looks misplaced.
|
||||
fn group_button(label: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder()
|
||||
.label(label)
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
button.add_css_class("suggested-action");
|
||||
button
|
||||
}
|
||||
|
||||
/// A section's only row while the share is being read.
|
||||
fn loading_row() -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
|
||||
row.add_prefix(&spinner());
|
||||
row
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
/// 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
|
||||
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
||||
role_wrap.add_suffix(&role_drop);
|
||||
// The role goes with the addresses it applies to, at the end of their row.
|
||||
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||
email_row.add_suffix(&role_drop);
|
||||
let message_row = adw::EntryRow::builder()
|
||||
.title(gettext("Message (optional)"))
|
||||
.build();
|
||||
let invite_btn = gtk4::Button::builder()
|
||||
.label(gettext("Send Invitations"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||
invite_btn.set_sensitive(false);
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
.build();
|
||||
invite_group.add(&email_row);
|
||||
invite_group.add(&role_wrap);
|
||||
invite_group.add(&message_row);
|
||||
invite_group.add(&invite_wrap);
|
||||
invite_group.add(&invite_btn);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title(gettext("People with access"))
|
||||
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.title(gettext("Public link"))
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&invite_group);
|
||||
content.append(&people);
|
||||
content.append(&link_group);
|
||||
let clamp = adw::Clamp::builder().child(&content).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
toolbar.set_content(Some(&scroll));
|
||||
// Until the share is read, each section says so rather than standing
|
||||
// empty under its title.
|
||||
let people_loading = loading_row();
|
||||
people.add(&people_loading);
|
||||
let link_loading = loading_row();
|
||||
link_group.add(&link_loading);
|
||||
|
||||
let content = adw::PreferencesPage::new();
|
||||
content.add(&invite_group);
|
||||
content.add(&people);
|
||||
content.add(&link_group);
|
||||
toolbar.set_content(Some(&content));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(pgettext("verb", "Share"))
|
||||
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
target,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||
});
|
||||
|
||||
// Check the addresses as they are typed: a malformed one marks the row as
|
||||
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
_ => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The share couldn't be read: what the sections showed (rows still loading,
|
||||
/// or ones that may now be stale) gives way to one line that says so.
|
||||
fn share_dialog_unread(state: &Rc<ShareDialog>) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
state.people.remove(&row);
|
||||
}
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
}
|
||||
let row = dim_row(&gettext("Couldn't load sharing"));
|
||||
state.people.add(&row);
|
||||
state.people_rows.borrow_mut().push(row.upcast());
|
||||
}
|
||||
|
||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
return;
|
||||
}
|
||||
for entry in entries {
|
||||
// A member is the plain case and needs no word; an invitation says
|
||||
// it is still waiting on the other side.
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => gettext("Member"),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
||||
ShareEntryKind::Member => String::new(),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||
|
||||
// External invites can't have their role changed; show it read-only.
|
||||
// Members and Proton invites get a role dropdown.
|
||||
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let create = gtk4::Button::builder()
|
||||
.label(gettext("Create Public Link"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
create.add_css_class("suggested-action");
|
||||
let create_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&create)
|
||||
.build();
|
||||
let create = group_button(&gettext("Create Public Link"));
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
let btn = create.clone();
|
||||
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&expiry_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
state.link_group.add(&create);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(expiry_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
rows.push(create.upcast());
|
||||
}
|
||||
}
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
}
|
||||
|
||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||
/// or online only. Folders and local files have none.
|
||||
/// or online only. A folder has one only when kept offline; local files have
|
||||
/// none.
|
||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||
let Hit::Drive(drive) = hit else {
|
||||
return None;
|
||||
};
|
||||
if drive.is_dir {
|
||||
return None;
|
||||
return drive
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if drive.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
|
||||
+175
-24
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
|
||||
follow, send,
|
||||
};
|
||||
use pdfs_core::service;
|
||||
|
||||
@@ -45,6 +46,8 @@ enum Phase {
|
||||
Offline,
|
||||
/// Changes keep failing; the user has to look.
|
||||
Attention,
|
||||
/// Mounted, but Proton refused the session: only signing in again helps.
|
||||
SessionExpired,
|
||||
/// Signed in, but no mount daemon is running.
|
||||
Disconnected,
|
||||
SignedOut,
|
||||
@@ -60,6 +63,7 @@ impl Phase {
|
||||
Phase::Paused => "media-playback-pause-symbolic",
|
||||
Phase::Offline => "network-offline-symbolic",
|
||||
Phase::Attention => "dialog-warning-symbolic",
|
||||
Phase::SessionExpired => "dialog-password-symbolic",
|
||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||
}
|
||||
}
|
||||
@@ -83,13 +87,17 @@ struct DriveState {
|
||||
paused: bool,
|
||||
/// Queued changes that keep failing, for the "N issues" item.
|
||||
failing: u64,
|
||||
/// Whether the daemon is an older release than the tray, left running by
|
||||
/// an upgrade; offers the restart that finishes it.
|
||||
outdated: bool,
|
||||
}
|
||||
|
||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||
/// transfer names the file and its percentage; several collapse to counts so the
|
||||
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||
/// running job speaks for itself — a scan or an index rebuild is still "busy",
|
||||
/// and the tray saying nothing there reads as "finished".
|
||||
/// running job speaks for itself — a scan or an index rebuild is still work
|
||||
/// going on, and the menu saying nothing there reads as "finished". The icon is
|
||||
/// [`busy`]'s call, not this line's.
|
||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
match items {
|
||||
[] => match jobs.first() {
|
||||
@@ -143,12 +151,12 @@ struct DriveTray {
|
||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(status @ Response::Status { .. }) => {
|
||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
// Same daemon is up, so a cheap follow-up request says what moves.
|
||||
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => (items, jobs),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
status_state(&status, sync, default_mountpoint)
|
||||
status_state(&status, &items, &jobs, default_mountpoint)
|
||||
}
|
||||
// Socket answered but with something unexpected — treat as up but odd.
|
||||
Ok(_) => unexpected_reply(default_mountpoint),
|
||||
@@ -162,10 +170,20 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
},
|
||||
// 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(
|
||||
@@ -189,6 +207,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,12 +224,18 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
||||
/// saying what it is moving right now.
|
||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
||||
/// The state a running daemon's [`Response::Status`] describes, with `items`
|
||||
/// and `jobs` saying what it is working on right now.
|
||||
fn status_state(
|
||||
status: &Response,
|
||||
items: &[TransferItem],
|
||||
jobs: &[JobItem],
|
||||
default_mountpoint: &Path,
|
||||
) -> DriveState {
|
||||
let Response::Status {
|
||||
mountpoint,
|
||||
pinned,
|
||||
@@ -219,6 +244,9 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -226,14 +254,29 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
};
|
||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
let outdated = daemon_outdated(version);
|
||||
let phase = if *session_expired {
|
||||
Phase::SessionExpired
|
||||
} else if *tokens_unsaved || outdated {
|
||||
Phase::Attention
|
||||
} else {
|
||||
phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || busy(items, jobs),
|
||||
)
|
||||
};
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
// Outranks everything: it is why nothing else moves, and the one
|
||||
// thing that fixes it is the user's to do.
|
||||
_ if *session_expired => gettext("Session expired — sign in again"),
|
||||
// Sync goes on meanwhile, but a reboot ends it until the next login.
|
||||
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
|
||||
// An upgrade replaced the files but not the running service, which
|
||||
// keeps the old release's bugs until it restarts.
|
||||
_ if outdated => gettext("Update not finished — restart Proton Drive"),
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
@@ -269,11 +312,20 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
sync: sync_line(items, jobs),
|
||||
failing: failing_ops,
|
||||
outdated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daemon is changing something: bytes moving, or a job the user
|
||||
/// is waiting on. A mirror folder's routine scan and the like are background
|
||||
/// jobs; they still get the menu line, but the icon spinning every two minutes
|
||||
/// for a scan that finds nothing reads as constant syncing.
|
||||
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
|
||||
!items.is_empty() || jobs.iter().any(|j| !j.background)
|
||||
}
|
||||
|
||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||
/// so first; failures outrank everything else the daemon reports.
|
||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||
@@ -342,7 +394,7 @@ impl Tray for DriveTray {
|
||||
|
||||
fn status(&self) -> Status {
|
||||
match self.state.phase {
|
||||
Phase::Attention => Status::NeedsAttention,
|
||||
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
|
||||
_ => Status::Active,
|
||||
}
|
||||
}
|
||||
@@ -400,9 +452,19 @@ impl Tray for DriveTray {
|
||||
// Nothing to mount without a session; the app's sign-in page is the
|
||||
// way in.
|
||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
||||
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
|
||||
open_manager(&["--page", "login"])
|
||||
})),
|
||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||
}
|
||||
|
||||
if self.state.outdated {
|
||||
items.push(item(&gettext("Restart Proton Drive"), |this| {
|
||||
service::restart();
|
||||
this.state.line = gettext("Connecting…");
|
||||
}));
|
||||
}
|
||||
|
||||
if self.state.mounted {
|
||||
items.push(
|
||||
StandardItem {
|
||||
@@ -476,6 +538,10 @@ fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The socket lives in the state dir, which nothing has made yet when the app
|
||||
// starts the tray on a fresh install (B194).
|
||||
dirs.ensure().context("create the app's directories")?;
|
||||
|
||||
// Ensure only one instance of the tray runs.
|
||||
let tray_sock = dirs.tray_socket();
|
||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||
@@ -534,11 +600,7 @@ fn main() -> Result<()> {
|
||||
heard.take(feed);
|
||||
}
|
||||
let st = match &heard.status {
|
||||
Some(status) => status_state(
|
||||
status,
|
||||
sync_line(&heard.items, &heard.jobs),
|
||||
&default_mountpoint,
|
||||
),
|
||||
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
|
||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||
None => poll_state(&socket, &default_mountpoint),
|
||||
};
|
||||
@@ -583,6 +645,95 @@ mod tests {
|
||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||
}
|
||||
|
||||
fn status(extra: &str) -> Response {
|
||||
status_from(env!("CARGO_PKG_VERSION"), extra)
|
||||
}
|
||||
|
||||
fn status_from(version: &str, extra: &str) -> Response {
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
|
||||
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
|
||||
"version":"{version}"{extra}}}}}"#
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
|
||||
/// good, and nothing said that signing in again was the way out.
|
||||
#[test]
|
||||
fn a_refused_session_outranks_everything_else() {
|
||||
let state = status_state(
|
||||
&status(r#","session_expired":true"#),
|
||||
&[],
|
||||
&[],
|
||||
Path::new("/m"),
|
||||
);
|
||||
assert_eq!(state.phase, Phase::SessionExpired);
|
||||
assert_eq!(state.line, "Session expired — sign in again");
|
||||
}
|
||||
|
||||
/// B61: the keyring refusing the refreshed tokens was only in the log.
|
||||
#[test]
|
||||
fn a_sign_in_the_keyring_refused_needs_attention() {
|
||||
let state = status_state(
|
||||
&status(r#","tokens_unsaved":true"#),
|
||||
&[],
|
||||
&[],
|
||||
Path::new("/m"),
|
||||
);
|
||||
assert_eq!(state.phase, Phase::Attention);
|
||||
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
|
||||
}
|
||||
|
||||
/// #33: a daemon an upgrade left running on the old release is named,
|
||||
/// above a pause, and the menu offers the restart that finishes it.
|
||||
#[test]
|
||||
fn a_daemon_older_than_the_tray_needs_a_restart() {
|
||||
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
|
||||
assert_eq!(state.phase, Phase::Attention);
|
||||
assert_eq!(state.line, "Update not finished — restart Proton Drive");
|
||||
assert!(state.outdated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_that_does_not_say_has_a_live_session() {
|
||||
let state = status_state(&status(""), &[], &[], Path::new("/m"));
|
||||
assert_eq!(state.phase, Phase::Paused);
|
||||
}
|
||||
|
||||
fn job(title: &str, background: bool) -> JobItem {
|
||||
JobItem {
|
||||
title: title.to_string(),
|
||||
detail: String::new(),
|
||||
done: 0,
|
||||
total: 0,
|
||||
background,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_routine_scan_is_not_busy_but_a_sync_is() {
|
||||
assert!(!busy(&[], &[]));
|
||||
assert!(!busy(&[], &[job("Checking Documents", true)]));
|
||||
assert!(busy(
|
||||
&[],
|
||||
&[
|
||||
job("Checking Documents", true),
|
||||
job("Syncing Photos", false)
|
||||
]
|
||||
));
|
||||
assert!(busy(&[], &[job("Uploading files", false)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_from_an_older_daemon_counts_as_busy() {
|
||||
let job: JobItem = serde_json::from_str(
|
||||
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!job.background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||
assert_eq!(issues_label(1), "1 issue — View");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
|
||||
"checkbox-symbolic",
|
||||
"computer-symbolic",
|
||||
"dialog-error-symbolic",
|
||||
"dialog-password-symbolic",
|
||||
"dialog-warning-symbolic",
|
||||
"document-edit-symbolic",
|
||||
"document-open-recent-symbolic",
|
||||
|
||||
+536
-455
File diff suppressed because it is too large.
Load diff
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user