Commit Graph

6 Commits

Author SHA1 Message Date
amir a0d759364e Security: guard against path traversal from hostile remotes
Build & Release APK / build (push) Successful in 12m42s
WebDAV already sanitizes server-supplied names, but SFTP passed entry.name
through unfiltered, and the engine had no central guard — a malicious or
compromised remote could return '../../x' and (on the JavaFile backend) write
outside the sync root.

- SyncEngine: isUnsafeSyncPath() rejects empty, absolute, and any '..'-segment
  path; every file is checked before any read/write/delete (covers all providers).
- SftpProvider.listFiles: drop '.'/'..' and names containing path separators.
- PathSafetyTest covers traversal, backslash, absolute, and empty cases.
2026-06-05 02:54:21 +00:00
amir 92cad9ca56 Add upload-only backup lifecycle tests
Build & Release APK / build (push) Successful in 12m49s
Characterizes the 'back up phone -> delete locally -> must stay in cloud'
scenario across the real multi-cycle engine state (upload saves null remote
metadata; next sync reconciles), asserting per delete behavior:
- KEEP  -> SKIP (cloud copy retained) — correct backup behavior
- ARCHIVE -> DELETE_REMOTE decision (engine moves to _Deleted/, preserved)
- MIRROR -> DELETE_REMOTE (cloud copy wiped) — footgun, and the current default
Also: upload-only never pulls a new remote file down; local edits still upload.
2026-06-05 02:36:44 +00:00
amir 62f9f015d6 Fix two stale SyncDecideTest cases (CI never ran tests before)
Build & Release APK / build (push) Successful in 12m53s
These contradicted deliberate later safety fixes in syncDecide:
- sub-second mtime delta is now SKIP (second-precision comparison was the
  fix for the FAT32/WebDAV phantom-change sync loops), not UPLOAD. Added a
  full-second-delta case to keep change-detection coverage.
- remote file with no state record now DOWNLOADs instead of DELETE_REMOTE:
  known==null can't be distinguished from a brand-new remote file, so the
  engine never deletes on ambiguity. Genuinely-deleted local files still
  have a state record and route to DELETE_REMOTE.

All 25 unit tests pass; assembleRelease builds and signs cleanly (compileSdk 35).
2026-06-05 02:32:16 +00:00
amir 3d7a8b5f3d fix: remote deletions not mirrored when file has no state record
When a file was uploaded before state-tracking worked (getFileMetadata was
broken), its SyncFileStateEntity was never saved. On next sync the engine
saw !local + remote + known=null and downloaded it back instead of deleting
it remotely, creating an infinite re-download loop.

Fix: syncDecide() now accepts hasPriorSyncState (derived from whether the
pair has any known states at all). On initial sync (no prior state) unknown
remote files are downloaded as before. Once the pair has been synced, unknown
remote-only files are treated as mirror-eligible deletions — same as if known
state existed — so locally-deleted files propagate to the remote correctly.

Verified live: 3 remote-only orphan files deleted from Nextcloud on sync.
Bump version to 1.0.12 (code 13).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:27 +00:00
amir 1d6a80e43d fix: SAF delete crash, getFileMetadata drop-first, MKCOL before upload
- LocalAccessor.Saf.delete() now uses docIdCache (same as openInputStream)
  and catches IllegalStateException from DocumentsContract.deleteDocument
  instead of propagating it through awaitAll() and crashing the whole sync
- WebDavProvider.getFileMetadata() passes dropFirst=false to parsePropfind
  since Depth:0 returns exactly 1 result (the file); drop(1) was discarding it
- SyncEngine.performSync() calls ensureRemoteDirs() before each upload so
  MKCOL is issued for any missing parent directories (405=exists is success)
- Bump version to 1.0.11 (code 12)

Verified against live Nextcloud: baseline ↑0 ↓0 ✗0, upload detection ↑1 ↓0 ✗0,
download detection ↑0 ↓1 ✗0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:07:54 +00:00
amir a9322d3214 fix: incremental sync + unit tests for decide() logic
Sync change detection (3rd attempt — now correct):
- After UPLOAD: save null remote metadata (server mtime unknown until
  next listing); decide() treats null remoteModifiedAt as "not changed"
- After DOWNLOAD: read actual local mtime via accessor.lastModifiedMs()
  so the stored value matches what walkFiles() sees on next scan
- SKIP reconciliation: if known state has null timestamps and both sides
  exist, fill them in — stabilises state within 2 syncs after first transfer
- Extract syncDecide() as internal top-level function for testability

Unit tests (14 cases covering all key scenarios):
- First sync decisions (upload/download/conflict)
- Second sync after upload with null remote metadata → SKIP
- Second sync after download with recorded local mtime → SKIP
- Epoch-millis precision: same ms = SKIP, +1ms = change detected
- Regression: epoch-second stored value would have differed → now correct
- Delete behaviour (MIRROR vs KEEP)
- Direction filters (UPLOAD_ONLY, DOWNLOAD_ONLY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:13:00 +00:00