* fix(library): count only uploaded, non-deleted books in synced toast The "N book(s) synced" toast shown on pull-to-refresh and the last-synced menu counted every non-deleted book record a pull returned, including books indexed in the cloud as metadata only whose file blob was never uploaded. Those books are never added to the library (updateLibrary requires uploadedAt && !deletedAt), so the toast over-reported. Extract the count into a pure countSyncedRecords(type, records) helper that excludes deleted records and, for books only, requires uploaded_at — matching what actually lands in the library. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(memory): record agent notes for recent fixes Persisted project-memory docs accumulated alongside recently merged work (biometric app-lock, download-file scope regression, inline-block column overflow, iOS instant-dict double popup, RSVP RTL words, web security advisories) plus MEMORY.md/share-feature index updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.8 KiB
name, description, metadata
| name | description | metadata | ||||||
|---|---|---|---|---|---|---|---|---|
| download-file-scope-android-regression | #4639 strict is_allowed broke ALL Android downloads to app data dir (covers/dicts/books); fix = app.path() base-dir membership, not glob scope |
|
Regression from security-advisories-web-2026-06 PR #4639 (commit 4025c4d7b). On Android every download_file into the app's own data dir failed: permission denied: path not in filesystem scope: /data/user/0/com.bilingify.readest/Readest/{Books/<hash>/cover.png, Dictionaries/<id>/*.mdx, ...}. The error string IS transfer_file.rs Error::Forbidden from ensure_path_allowed.
Root cause (non-obvious): app.fs_scope().is_allowed(p) returns false for the app's own files on Android. FsExt::fs_scope() returns the GLOBAL state::<Scope>().scope, but the capability scope patterns that cover the data dir ($APPDATA/Readest/**/*, **/Readest/**/*) are command-scoped, NOT in that global scope. The fs plugin's resolve_path (tauri-plugin-fs commands.rs) passes because it checks fs_scope.scope.is_allowed(p) || scope.is_allowed(p) where the 2nd scope is rebuilt from global_scope.allows()+command_scope.allows() per-command — that's where those patterns live. This is the SAME gap dir_scanner::read_dir works around with || contains("Readest"). So #4639's note "Chose STRICT is_allowed (NOT read_dir's contains-Readest hatch)" was the bug — strict is_allowed rejects the app's own dir on Android.
Why fs-plugin writes work but download_file didn't: app writes via baseDir: AppData + relative path → webview.path().resolve(rel, AppData) → canonical form matched by the per-command scope. download_file/upload_file use raw tokio::fs with a JS-supplied ABSOLUTE path, so none of that applies.
WRONG first attempt (don't repeat): canonicalizing the symlink (/data/user/0/<pkg> → /data/data/<pkg>, since is_allowed only canonicalizes EXISTING paths and a download target doesn't exist yet) then re-calling is_allowed. Verified on-device it STILL fails — the patterns aren't in the global scope at ALL, so no path form matches. The canonicalize-existing-ancestor helper is still useful, just for the prefix check below, not for is_allowed.
Shipped interim fix (PR #4651, commit 75b469931): owner rejected the base-dir-membership version as over-engineered ("bloody hard coded" dir list) and asked to mirror dir_scanner instead. ensure_path_allowed = reject relative+.. (has_disallowed_components) → if app.fs_scope().is_allowed(p) || is_within_app_storage(p) Ok. is_within_app_storage(file_path, app_identifier) = file_path.contains("Readest") || file_path.contains(app_identifier) where app_identifier = &app.config().identifier (NOT a hardcoded literal; config() is inherent on AppHandle — NO use tauri::Manager). The bundle id (com.bilingify.readest) is in EVERY Android sandbox path incl. the cache dir, so it closes the OPDS-to-$APPCACHE gap that contains("Readest") alone misses (Readest is the DATA_SUBDIR, capital-R; cache dir has only the lowercase bundle id). .. rejection keeps GHSA-55vr-pvq5-6fmg (~/.ssh/id_rsa has neither marker). Substring posture = same as dir_scanner. Follow-up (deferred): replace the substring fallback with BaseDirectory+relative resolved via app.path() (the app already has appService.resolvePath() → {baseDir, fp}; callers currently flatten it to an absolute string) so targets are in-scope by construction.
(Rejected earlier attempt, kept for context: base-dir-membership via app.path() dirs + is_inside_any canonicalizing both sides for the /data/user/0↔/data/data symlink. Correct but owner found the dir enumeration ugly.)
On-device verify recipe (Xiaomi fuxi 2211133C, real release-signed devtools build): pnpm dev-android = tauri android build -t aarch64 -- --features devtools && adb install -r .../app-universal-release.apk. Local keystore (gen/android/keystore.properties → /Users/chrox/dev/Android/keys/upload-readest-keystore.jks, alias upload) matches installed signer → -r PRESERVES user data (dicts/books). CDP probe: adb forward tcp:9333 localabstract:webview_devtools_remote_<pid> (socket name = app pid; stale sockets linger — pick the one matching adb shell pidof); fetch /json/list via node http (NOT curl — mishandles WebView framing); Node v24 has global WebSocket. Raw invoke('download_file',...) needs a Channel for on_progress: pass { ['__TAURI_TO_IPC_KEY__']: () => '__CHANNEL__:'+I.transformCallback(()=>{}) }. Result: in-scope /data/user/0/.../Readest/x.bin → OK; /data/local/tmp/evil.bin → still Forbidden. appData=/data/user/0/com.bilingify.readest (no /files); appCache/temp=.../cache.