Skip to content

Add Amazon S3 / S3-compatible storage backend#3261

Open
Nicolas-GUILLAUME wants to merge 2 commits into
PhilippC:mainfrom
Nicolas-GUILLAUME:feature/s3-storage-backend
Open

Add Amazon S3 / S3-compatible storage backend#3261
Nicolas-GUILLAUME wants to merge 2 commits into
PhilippC:mainfrom
Nicolas-GUILLAUME:feature/s3-storage-backend

Conversation

@Nicolas-GUILLAUME

Copy link
Copy Markdown

📝 Description

Adds an Amazon S3 / S3-compatible storage backend (s3://) so a .kdbx can live on object storage, plus two small pre-existing bug fixes found while testing cloud saves. Two commits.

New storage backend (commit 1)

  • S3FileStorage (IFileStorage, modeled on NetFtpFileStorage) backed by AWSSDK.S3, supporting Amazon S3, Wasabi, Backblaze B2, Cloudflare R2 and custom/MinIO endpoints.
  • Connection data (provider, region/endpoint, bucket, access/secret key) is encoded into the IOConnectionInfo path like the FTP/SMB backends. Gated behind #if !NoNet; the AWSSDK.S3 PackageReference is excluded for the NoNet (offline) flavor.
  • The credentials dialog takes a fully qualified object key rather than browsing the bucket, so a least-privilege IAM user only needs s3:GetObject + s3:PutObject (no s3:ListBucket). It shows a live "Resulting URL" preview, opens the exact object directly, and warns when the object key repeats the bucket name. ListContents therefore throws NotSupportedException (the backend never browses).
  • Saves are atomic via a conditional If-Match write (412 → "changed on the server"; 501 → retried once without the precondition for providers lacking conditional writes). The ambiguous 403 S3 returns without ListBucket (missing vs forbidden) is surfaced with a clear message, and the write path reports its own 403 (s3:PutObject).
  • The bug-prone path codec (URL-encoding + string surgery) is isolated in a small platform-agnostic library Kp2aS3PathCodec (net8.0, no Android deps) with xunit tests (Kp2aS3PathCodec.Tests) covering the settings serialize/parse round-trip (incl. secrets containing + : # / =), ParsePath, full-path and preview-URL building, and bucket-in-key detection.

Pre-existing fixes (commit 2 — storage-agnostic, surfaced while testing cloud saves)

  • DateTime.NowDateTime.UtcNow for DB meta timestamps (EntryTemplatesGroupChanged in AddTemplateEntries/DeleteRunnable, MasterKeyChanged in SetPassword). KeePassLib stores times as UTC and asserts on the kind, which aborts Debug builds when creating a database or changing the master key.
  • OnOperationFinishedHandler.DisplayMessage now marshals to the main looper — it previously created toasts/dialogs on the background save/load thread, crashing on any save failure with "Can't toast on a thread that has not called Looper.prepare()". Create/save errors are also shown in a dismissible dialog instead of a short-lived toast.

🔗 Related Issue

N/A — new feature.

🛠️ Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change
  • Documentation update

✅ Checklist

  • My code follows the style guidelines of this project. (2-space indent per src/.editorconfig, GPLv3 headers on new files)
  • I have performed a self-review of my own (or any AI generated) code.
  • I have added/updated tests — new Kp2aS3PathCodec.Tests (22 cases) for the path codec. (The IFileStorage backends themselves have no unit-test harness; this extracts the testable part.)
  • All new and existing tests passed locally. (new codec tests pass via dotnet test; the autofill test project is untouched.)
  • I have commented my code, particularly in hard-to-understand areas.
  • I have built the app locally, verified that it builds and tested the changes thoroughly.

🧪 Testing

  • Builds: Net Debug + Release and NoNet Debug — all 0 errors. Release link mode is the default SdkOnly, which does not trim the AWSSDK.S3/AWSSDK.Core assemblies (verified they're packaged in the Release APK), so no TrimmerRootAssembly is needed.
  • Kp2aS3PathCodec.Tests: 22/22 pass.
  • Manual: on a physical device and an emulator, against a real AWS S3 bucket — storage tile → credentials dialog (provider dropdown, live URL preview, "Signing region (optional)" for Custom), open an existing .kdbx, create a new database, edit + save, and the error paths (missing object, forbidden object, missing s3:PutObject, bucket-name-in-key warning).

📌 Reviewer notes

  • New dependency: AWSSDK.S3 3.7.511.8 (ships a netstandard2.0 target → compatible with net9.0-android), excluded from the NoNet flavor via a Condition.
  • New projects: src/Kp2aS3PathCodec and src/Kp2aS3PathCodec.Tests (both net8.0) added to KeePass.sln; tests run with cd src/Kp2aS3PathCodec.Tests && dotnet test (mirrors the existing autofill test project, so they could slot into CI the same way).
  • ⚠️ The access key and secret key persist in the recent-files store together with the file location, exactly like the FTP/WebDav backends — called out here so it's a conscious decision.
  • The S3 tile icon is the AWS S3 architecture icon, converted to an Android vector drawable.

🤖 Generated with Claude Code

Nicolas-GUILLAUME and others added 2 commits June 27, 2026 22:59
Add an s3:// IFileStorage implementation (S3FileStorage) backed by AWSSDK.S3,
supporting Amazon S3, Wasabi, Backblaze B2, Cloudflare R2 and custom/MinIO
endpoints. Connection data (provider, region/endpoint, bucket, access key,
secret key) is encoded into the IOConnectionInfo path like the FTP/SMB
backends, so the recent-files store works with no extra plumbing. The backend
is gated behind #if !NoNet and the AWSSDK.S3 PackageReference is excluded for
the NoNet (offline) flavor.

Design:
- The credentials dialog takes a fully qualified object key instead of browsing
  the bucket, so a least-privilege IAM user only needs s3:GetObject and
  s3:PutObject (no s3:ListBucket). It shows a live "Resulting URL" preview,
  opens the exact object directly, and warns when the object key repeats the
  bucket name. ListContents therefore throws NotSupportedException.
- Saves are atomic via a conditional If-Match write (412 -> "changed on the
  server"; 501 -> retried once without the precondition for providers lacking
  conditional writes). The ambiguous 403 that S3 returns without s3:ListBucket
  (missing vs forbidden) is surfaced as a clear message, and the write path
  reports its own 403 mentioning s3:PutObject.

The bug-prone path codec (URL-encoding + string surgery) lives in a separate
platform-agnostic library, Kp2aS3PathCodec (net8.0, no Android deps), with unit
tests (Kp2aS3PathCodec.Tests, xunit) covering the settings serialize/parse
round-trip (including secrets containing + : # / =), ParsePath, BuildFullPath,
BuildPreviewUrl and bucket-in-key detection.

Note: the access key and secret key persist in the recent-files store together
with the file location, exactly like the FTP/WebDav backends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two pre-existing, storage-agnostic issues surfaced while testing cloud saves:

- Several operations set database meta timestamps with local DateTime.Now
  instead of DateTime.UtcNow (EntryTemplatesGroupChanged in AddTemplateEntries
  and DeleteRunnable, MasterKeyChanged in SetPassword). KeePassLib stores times
  as UTC and asserts on the kind, which aborts Debug builds when creating a
  database or changing the master key. Use UtcNow (same instant, correct kind).

- OnOperationFinishedHandler.DisplayMessage created toasts/dialogs directly on
  the background save/load thread, crashing on any failure with "Can't toast on
  a thread that has not called Looper.prepare()". Marshal the display to the main
  looper, and show create/save errors in a dismissible dialog instead of a
  short-lived toast (CreateDatabaseActivity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Nicolas-GUILLAUME Nicolas-GUILLAUME marked this pull request as ready for review June 28, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant