diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c69ca..6745a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # RustBox changelog +### 0.2.4 +- Fixed S3-compatible storage over explicitly allowed plain HTTP endpoints by applying `storage.allow_http` to the final `ClientOptions` object instead of setting it before `with_client_options()` replaces those options. This fixes Garage/MinIO/Ceph-style internal HTTP endpoints without weakening the HTTPS default. +- Added an HTTP S3 regression test that verifies a request reaches an explicitly allowed local S3-compatible endpoint. +- Updated the pinned Rust toolchain/MSRV to Rust 1.99.1. +- Updated RustBox Docker build/runtime images from Debian 12 Bookworm to Debian 13 Trixie (`rust:1.99.1-trixie`, `debian:trixie-slim`) and switched the runtime OpenSSL package to Trixie's `libssl3t64`. +- Updated PostgreSQL 17 Compose images from Bookworm to the supported Trixie variant. +- No schema migration is required; runtime schema remains V047 and V048 remains the next migration number. +- Bumped the application/source release to 0.2.4. + ### 0.2.3 - Fixed API fallback routing so administratively disabled or otherwise unknown `/api/*` endpoints return HTTP 404 instead of being caught by the SPA fallback and returning the UI with HTTP 200. - Added administrator feature switches for ManageSieve, diagnostics and About; disabled Sieve/diagnostics routes are not registered, and the matching settings UI is removed while keyboard shortcuts remain available. diff --git a/Cargo.lock b/Cargo.lock index efedc8e..8c6c617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,13 +699,11 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc-fast" -version = "1.8.2" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85d9be5297a59f1b7651fd2711a1f4461929f53b182b394df0df15b3a387ef51" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ - "crc", "digest 0.10.7", - "rustversion", "spin 0.10.1", ] @@ -3508,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.3" +version = "0.2.4" dependencies = [ "aes-gcm", "async-imap", @@ -4979,9 +4977,9 @@ checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" [[package]] name = "uuid" -version = "1.26.1" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 56d64a8..bd3947b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "rustbox" -version = "0.2.3" +version = "0.2.4" edition = "2024" -rust-version = "1.88" +rust-version = "1.99.1" default-run = "rustbox" [dependencies] diff --git a/NOTICE.md b/NOTICE.md index 7c8d9e1..d256330 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.3 is derived from the Oxi webmail project: +RustBox 0.2.4 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 15a026e..0e4ca93 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.3 +# RustBox 0.2.4 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -34,7 +34,7 @@ rustbox/ `build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`. -## Storage and search in 0.2.3 +## Storage and search in 0.2.4 RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared. @@ -224,7 +224,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.2.3 release" +git commit -m "RustBox 0.2.4 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 6ce80d8..c638074 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,11 @@ -RustBox 0.2.3 release manifest +RustBox 0.2.4 release manifest -0.2.3 feature/configuration changes: +0.2.4 feature/configuration changes: +- fixes S3-compatible HTTP endpoints by carrying allow_http in the final object_store ClientOptions +- keeps HTTPS-only behavior as the default; plain HTTP still requires storage.allow_http=true +- adds a local HTTP S3 regression test for the request path +- pins Rust 1.99.1 and uses rust:1.99.1-trixie / debian:trixie-slim with libssl3t64 +- moves PostgreSQL 17 Compose services to postgres:17-trixie - optional Sieve, diagnostics and About feature switches - API namespace has an explicit 404 fallback so disabled/unknown API routes cannot be served by the SPA fallback - opt-in security response headers (disabled by default) @@ -66,14 +71,14 @@ Docker examples: - docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch) Operations/docs: -- README.md documents 0.2.3 storage/search configuration +- README.md documents 0.2.4 storage/search configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.3 -- rust-toolchain.toml pins Rust 1.88.0 +- Cargo.toml version = 0.2.4 +- rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is intentionally not shipped; the release gate regenerates it before locked checks - CI and scripts/build-release.sh generate Cargo.lock, then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 15c9a6c..e307bdf 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,15 +1,19 @@ -RustBox 0.2.3 - final source validation +RustBox 0.2.4 - final source validation -0.2.3 scope: +0.2.4 scope: +- S3-compatible plain-HTTP client-options fix for explicitly allowed internal endpoints +- regression coverage for an HTTP S3-compatible endpoint +- Rust 1.99.1 toolchain/MSRV +- Debian Trixie RustBox images and PostgreSQL 17 Trixie Compose variants - feature gates for Sieve/diagnostics/About and UI policy exposure - API fallback isolation: unknown/disabled `/api/*` routes return 404 and never fall through to the SPA UI - reset-all-settings preserving account/mail data - optional browser security headers - Docker two-node application topology with Envoy/Valkey/OpenSearch/PostgreSQL/Garage -- version metadata updated to 0.2.3 +- version metadata updated to 0.2.4 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (196/196 checks passed) +- offline release validator: scripts/validate-release-static.py (207/207 checks passed) - all maintained TOML examples parse successfully - Docker Compose topology/config references are validated by the offline release validator - JSON files parse successfully @@ -35,7 +39,7 @@ Completed in this environment: - 0.2.1 OpenPGP visibility, OpenSearch backpressure and HTTP/dev cookie fixes remain in place Not executed in this packaging environment: -- cargo fmt/test/clippy/build with Rust 1.88, because Rust/Cargo is not installed here and outbound DNS is unavailable +- cargo fmt/test/clippy/build with Rust 1.99.1, because Rust/Cargo is not installed in this validation environment - live PostgreSQL/MySQL/S3/NFS/OpenSearch integration tests The final release gate on a Rust host is: diff --git a/config.example.toml b/config.example.toml index 558961e..3f5aa16 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.3 +# RustBox 0.2.4 host = "127.0.0.1" port = 3001 environment = "production" diff --git a/docker/Dockerfile b/docker/Dockerfile index 0024fde..6b513fc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,11 +1,11 @@ -FROM rust:1.88-bookworm AS builder +FROM rust:1.99.1-trixie AS builder WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* COPY . . RUN cargo generate-lockfile && cargo build --release --locked -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* +FROM debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3t64 && rm -rf /var/lib/apt/lists/* RUN useradd --system --uid 10001 --create-home --home-dir /var/lib/rustbox rustbox COPY --from=builder /build/target/release/rustbox /usr/local/bin/rustbox USER rustbox diff --git a/docker/README.md b/docker/README.md index fa9d117..9d38dd2 100644 --- a/docker/README.md +++ b/docker/README.md @@ -10,8 +10,8 @@ Two RustBox nodes + Envoy + Valkey + PostgreSQL + OpenSearch + Garage S3: ```bash PORT=3009 \ -IMAP_HOST=mail.linuxiarz.pl \ -SMTP_HOST=mail.linuxiarz.pl \ +IMAP_HOST=mail.example.com \ +SMTP_HOST=mail.example.com \ docker compose -p rustbox-ha -f compose-ha-garage.yml up --build ``` @@ -26,16 +26,16 @@ PostgreSQL + S3/SILO + OpenSearch: ```bash PORT=3009 \ -IMAP_HOST=mail.linuxiarz.pl \ -SMTP_HOST=mail.linuxiarz.pl \ +IMAP_HOST=mail.example.com \ +SMTP_HOST=mail.example.com \ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml up --build ``` SQLite, local blobs and local Tantivy: ```bash -IMAP_HOST=mail.linuxiarz.pl \ -SMTP_HOST=mail.linuxiarz.pl \ +IMAP_HOST=mail.example.com \ +SMTP_HOST=mail.example.com \ docker compose -p rustbox -f compose-single-sqlite.yml up --build ``` @@ -44,8 +44,8 @@ MySQL + NFS + OpenSearch: ```bash NFS_SERVER=10.0.0.10 \ NFS_EXPORT=/exports/rustbox \ -IMAP_HOST=mail.linuxiarz.pl \ -SMTP_HOST=mail.linuxiarz.pl \ +IMAP_HOST=mail.example.com \ +SMTP_HOST=mail.example.com \ docker compose -p rustbox -f compose-mysql-nfs-opensearch.yml up --build ``` @@ -201,4 +201,4 @@ docker compose -p rustbox -f compose-postgres-s3-opensearch.yml exec opensearch -d '{"index.blocks.read_only_allow_delete":null}' ``` -RustBox 0.2.3 retains the OpenSearch outbox backoff introduced in 0.2.2, so these 429/flood-stage responses no longer produce one warning per queued event on a full disk. +RustBox 0.2.4 retains the OpenSearch outbox backoff introduced in 0.2.2, so these 429/flood-stage responses no longer produce one warning per queued event on a full disk. diff --git a/docker/compose-ha-garage.yml b/docker/compose-ha-garage.yml index 15892bb..6df3f04 100644 --- a/docker/compose-ha-garage.yml +++ b/docker/compose-ha-garage.yml @@ -42,7 +42,7 @@ x-rustbox-common: &rustbox-common services: postgres: - image: postgres:17-bookworm + image: postgres:17-trixie environment: POSTGRES_DB: rustbox POSTGRES_USER: rustbox @@ -103,7 +103,7 @@ services: - garage-data:/var/lib/garage/data networks: rustbox-ha: - ipv4_address: 172.28.80.10 + ipv4_address: 10.240.6.10 garage-init: build: @@ -154,4 +154,4 @@ networks: rustbox-ha: ipam: config: - - subnet: 172.28.80.0/24 + - subnet: 10.240.6.0/24 diff --git a/docker/compose-postgres-s3-opensearch.yml b/docker/compose-postgres-s3-opensearch.yml index b37bdb4..55d9e0a 100644 --- a/docker/compose-postgres-s3-opensearch.yml +++ b/docker/compose-postgres-s3-opensearch.yml @@ -2,7 +2,7 @@ name: rustbox services: postgres: - image: postgres:17-bookworm + image: postgres:17-trixie environment: POSTGRES_DB: rustbox POSTGRES_USER: rustbox diff --git a/docker/configs/ha-garage.toml b/docker/configs/ha-garage.toml index 1d104de..b6401bb 100644 --- a/docker/configs/ha-garage.toml +++ b/docker/configs/ha-garage.toml @@ -10,7 +10,7 @@ sieve_enabled = false diagnostics_enabled = true about_enabled = true security_headers_enabled = false -trusted_proxies = "172.28.80.0/24" +trusted_proxies = "10.240.6.0/24" [storage] backend = "s3" diff --git a/docker/garage/garage.toml b/docker/garage/garage.toml index 81c389e..35562e9 100644 --- a/docker/garage/garage.toml +++ b/docker/garage/garage.toml @@ -3,7 +3,7 @@ data_dir = "/var/lib/garage/data" db_engine = "sqlite" replication_factor = 1 rpc_bind_addr = "0.0.0.0:3901" -rpc_public_addr = "172.28.80.10:3901" +rpc_public_addr = "10.240.6.10:3901" rpc_secret = "8f2ec1b94f2ff5fc39391d8e1666929f8d6adc4f2270d5c950866a0a65e5f4b3" [s3_api] diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3a7f2ec..0f335e0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,8 @@ -# RustBox 0.2.3 - implementation status +# RustBox 0.2.4 - implementation status -This file tracks the `0.2.3` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the `0.2.4` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. -## 0.2.3 storage/search architecture +## 0.2.4 storage/search architecture - SQL backend selection (`sqlite`/`mysql`/`pgsql`) is independent from blob storage and search backend selection. - Cluster mode clears an implicit/default `data_dir`; PostgreSQL/MySQL single-node deployments may still intentionally use `data_dir`-backed filesystem storage and local Tantivy. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4cc5fe9..f6fcd82 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source. -## Updating an existing checkout to 0.2.3 +## Updating an existing checkout to 0.2.4 Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files. diff --git a/docs/STORAGE_SEARCH_OPERATIONS.md b/docs/STORAGE_SEARCH_OPERATIONS.md index 0436f5b..3c2e3b4 100644 --- a/docs/STORAGE_SEARCH_OPERATIONS.md +++ b/docs/STORAGE_SEARCH_OPERATIONS.md @@ -1,6 +1,6 @@ -# RustBox 0.2.3 storage/search operations +# RustBox 0.2.4 storage/search operations -RustBox 0.2.3 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. +RustBox 0.2.4 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed. ## Deployment matrix @@ -83,4 +83,4 @@ Cross-node realtime event fan-out remains separate from blob/search storage and ## Validation status -The source package includes `scripts/validate-release-static.py` for offline structural checks. Full release validation still requires a Rust 1.88 build host and live service tests against the exact PostgreSQL/MySQL, S3/NFS and OpenSearch infrastructure used in production. Live SILO/MinIO/AWS/Ceph RGW and multi-node acceptance items remain intentionally unchecked in `TODO.md` until executed. +The source package includes `scripts/validate-release-static.py` for offline structural checks. Full release validation still requires a Rust 1.99.1 build host and live service tests against the exact PostgreSQL/MySQL, S3/NFS and OpenSearch infrastructure used in production. Live SILO/MinIO/AWS/Ceph RGW and multi-node acceptance items remain intentionally unchecked in `TODO.md` until executed. diff --git a/migrations/README.md b/migrations/README.md index d087d4f..358f62b 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,6 @@ # Database schemas and migrations -RustBox 0.2.3 keeps SQL, blob storage and search as independent concerns: +RustBox 0.2.4 keeps SQL, blob storage and search as independent concerns: ```text database_backend = sqlite | mysql | pgsql @@ -34,7 +34,7 @@ Account data is isolated inside the selected physical database with a determinis Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path. -The next migration number after RustBox 0.2.3 is **V048**. +The next migration number after RustBox 0.2.4 is **V048**. Redis and Redis Sentinel do not use SQL migrations. diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index b1f7608..0938205 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -1,4 +1,4 @@ -# RustBox 0.2.3 - packaged production configuration (DEB/RPM) +# RustBox 0.2.4 - packaged production configuration (DEB/RPM) # # This file contains all supported RustBox configuration options. # Commented options are optional and do not change the packaged defaults. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d..9ec55b3 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.99.1" components = ["rustfmt", "clippy"] diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index f61f175..51f92c8 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static/offline release checks for RustBox 0.2.3. +"""Static/offline release checks for RustBox 0.2.4. These checks intentionally avoid pretending to replace `cargo test`: they validate configuration, SQL migrations, Docker examples, query-catalog parity and release @@ -49,10 +49,10 @@ def extract_section(text: str, begin: str, end: str) -> str: # Release/toolchain metadata. cargo = tomllib.loads(read("Cargo.toml")) -require(cargo["package"]["version"] == "0.2.3", "Cargo package version is 0.2.3") -require(cargo["package"]["rust-version"] == "1.88", "Cargo rust-version is 1.88") +require(cargo["package"]["version"] == "0.2.4", "Cargo package version is 0.2.4") +require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1") toolchain = tomllib.loads(read("rust-toolchain.toml")) -require(toolchain["toolchain"]["channel"] == "1.88.0", "rust-toolchain pins Rust 1.88.0") +require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1") require(not (ROOT / "Cargo.lock").exists(), "stale Cargo.lock is not shipped") require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script regenerates Cargo.lock") release_script = read("scripts/build-release.sh") @@ -65,15 +65,28 @@ require("make_zip.py" in package_script and "sha256sum" in package_script, "rele require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks") require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate") require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate") -require(read("README.md").startswith("# RustBox 0.2.3"), "README identifies RustBox 0.2.3") -require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.3"), "release manifest identifies RustBox 0.2.3") -require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.3"), "release validation identifies RustBox 0.2.3") -require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.3"), "implementation status identifies RustBox 0.2.3") +require(read("README.md").startswith("# RustBox 0.2.4"), "README identifies RustBox 0.2.4") +require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.4"), "release manifest identifies RustBox 0.2.4") +require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.4"), "release validation identifies RustBox 0.2.4") +require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.4"), "implementation status identifies RustBox 0.2.4") require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists") require('imap_host = ""' not in read("config.dev.toml"), "development config does not override IMAP host with an invalid empty value") require(not re.search(r"(?m)^IMAP_HOST=\s*$", read(".env.example")), "environment example does not set an invalid empty IMAP host") require("pgp_enabled: bool" in read("src/routes/auth.rs"), "auth policy exposes OpenPGP availability") require("setOpenPgpAvailability" in read("src/web/js/72-openpgp-identities.js"), "UI hides administratively disabled OpenPGP") +storage_rs = read("src/storage/mod.rs") +require( + ".with_allow_http(storage.allow_http)" in storage_rs.split("let client_options = ClientOptions::new()", 1)[1].split("let retry =", 1)[0], + "S3 allow_http is applied to the final ClientOptions", +) +require( + ".with_allow_http(storage.allow_http)" not in storage_rs.split("let mut builder = AmazonS3Builder::from_env()", 1)[1].split("if let Some(endpoint)", 1)[0], + "S3 builder does not overwrite allow_http with replacement client options", +) +require( + "s3_http_endpoint_works_when_explicitly_allowed" in storage_rs, + "S3 HTTP regression test is present", +) config_rs = read("src/config.rs") routes_mod = read("src/routes/mod.rs") require("pub sieve_enabled: bool" in config_rs and "pub diagnostics_enabled: bool" in config_rs and "pub about_enabled: bool" in config_rs, "configuration exposes Sieve, diagnostics and About feature gates") @@ -215,7 +228,16 @@ require(ha["cluster_mode"] is True and ha["session_backend"] == "redis", "HA/Gar require(ha["redis_url"] == "redis://valkey:6379/", "HA/Garage config targets Valkey for shared sessions") require(ha["storage"]["backend"] == "s3" and ha["storage"]["endpoint"] == "http://garage:3900", "HA/Garage config stores blobs in Garage S3") require(ha["search"]["backend"] == "opensearch", "HA/Garage config uses OpenSearch") +dockerfile = read("docker/Dockerfile") +require("FROM rust:1.99.1-trixie AS builder" in dockerfile, "Docker builder pins Rust 1.99.1 on Debian Trixie") +require("FROM debian:trixie-slim" in dockerfile, "Docker runtime uses Debian Trixie slim") +require("libssl3t64" in dockerfile and "libssl3 " not in dockerfile, "Docker runtime uses the Debian Trixie OpenSSL runtime package") +require("bookworm" not in dockerfile.lower(), "RustBox Dockerfile no longer uses Debian Bookworm") +require("postgres:17-trixie" in read("docker/compose-postgres-s3-opensearch.yml"), "PostgreSQL/S3 Compose uses PostgreSQL 17 Trixie") +require("bookworm" not in read("docker/compose-postgres-s3-opensearch.yml").lower(), "PostgreSQL/S3 Compose no longer uses Bookworm") ha_compose = read("docker/compose-ha-garage.yml") +require("postgres:17-trixie" in ha_compose, "HA Compose uses PostgreSQL 17 Trixie") +require("bookworm" not in ha_compose.lower(), "HA Compose no longer uses Bookworm") require("rustbox-1:" in ha_compose and "rustbox-2:" in ha_compose, "HA Compose defines two RustBox application nodes") for service in ["postgres:", "valkey:", "opensearch:", "garage:", "envoy:"]: require(service in ha_compose, f"HA Compose includes {service.removesuffix(':')}") diff --git a/src/auth/user_data.rs b/src/auth/user_data.rs index 555a90f..5d3da80 100644 --- a/src/auth/user_data.rs +++ b/src/auth/user_data.rs @@ -49,8 +49,6 @@ pub fn provision_user_data_with_search( .map_err(|e| format!("failed to create search directory: {e}"))?; } - // Ensure this account has its isolated tables in the configured RustBox database. - // The same database backend stores sessions unless session_backend=redis. let conn = crate::db::pool::open_user_db(data_dir, user_hash) .map_err(|e| format!("failed to initialize user database: {e}"))?; drop(conn); diff --git a/src/db/pool.rs b/src/db/pool.rs index 635b44a..6cff381 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -20,8 +20,6 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql"); const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql"); const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); -// The squashed legacy baseline is V046. Runtime migrations start at V047 and -// are stored as real files for every supported SQL engine. const SQLITE_MIGRATIONS: &[(u32, &str)] = &[( 47, include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"), @@ -65,7 +63,6 @@ const TENANT_TABLES: &[&str] = &[ "user_meta", ]; -// Parent tables precede children so legacy SQLite data can be copied with foreign keys enabled. const IMPORT_TABLES: &[&str] = &[ "calendar_events", "calendar_settings", @@ -229,12 +226,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> { conn.execute_batch(&global_schema(SQLITE_SCHEMA)?) .map_err(|e| format!("SQLite global schema initialization failed: {e}"))?; - // Unit tests create many independent temporary data directories in one - // process. Pinning the first SQLite path in the process-wide OnceLock - // makes later tests reuse a TempDir that may already have been deleted, - // causing a cascade of "unable to open database file" failures. - // Initialize the requested test database, but let open_user_db() resolve - // each test's own data_dir instead of publishing this path globally. #[cfg(test)] { return Ok(()); @@ -287,9 +278,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> { let schema = global_schema(PGSQL_SCHEMA)?; let init_pool = pool.clone(); runtime_wait(&runtime, async move { - // PostgreSQL emits NOTICE for every CREATE ... IF NOT EXISTS that - // already exists. Keep expected idempotent startup DDL quiet without - // changing application-wide logging or server settings. let mut tx = init_pool .begin() .await @@ -298,11 +286,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> { .execute(&mut *tx) .await .map_err(|e| format!("failed to quiet PostgreSQL schema notices: {e}"))?; - // `raw_sql(...).execute(&mut PgConnection)` can trigger SQLx's - // "Executor is not general enough" lifetime error when this - // future is spawned onto the dedicated database runtime. Execute - // the unprepared multi-statement DDL directly through `Executor` - // instead; this is SQLx's supported path for raw DDL batches. (&mut *tx) .execute(schema.as_str()) .await @@ -460,7 +443,6 @@ pub trait ToDbValue { } } -// Kept explicit rather than clever: the macro above intentionally stays simple for rustfmt/parser compatibility. impl ToDbValue for i64 { fn to_db_value(&self) -> DbValue { DbValue::I64(Some(*self)) @@ -601,10 +583,6 @@ impl IntoDbParams for [(); 0] { } } -// Do not use a const-generic array implementation here. For an empty array (`[]`) -// Rust cannot infer the element type and sees both `[(); 0]` and `[T; 0]` as valid -// `IntoDbParams` candidates. Implement only non-empty array lengths explicitly so -// legacy/rusqlite-style `[]` call sites remain unambiguous. macro_rules! impl_into_db_params_array { ($($n:expr),+ $(,)?) => { $( @@ -1457,8 +1435,6 @@ fn adapt_sql_params( return (sql, params); } - // Dynamic query builders in the application use SQLite-style ?N placeholders. - // Normalize them here so the same higher-level logic can target all engines. let chars: Vec = sql.chars().collect(); let mut output = String::with_capacity(sql.len() + 16); let mut reordered = Vec::new(); @@ -1522,8 +1498,6 @@ fn adapt_sql_params( sql = output; if backend == DatabaseBackend::Mysql && !reordered.is_empty() { - // If numbered placeholders were expanded, bind in occurrence order. - // Plain MySQL catalog queries already use '?' and are handled the same way. return (adapt_dialect_sql(backend, sql), reordered); } (adapt_dialect_sql(backend, sql), params) @@ -1855,9 +1829,6 @@ fn tenant_prefix(user_hash: &str) -> Result { return Err("empty user hash for database isolation".to_string()); } - // Production user hashes are SHA-256 hex strings. Tests and legacy callers may - // use symbolic identifiers; hash those first so table prefixes remain safe, - // deterministic and restricted to ASCII hex without weakening isolation. let normalized = if user_hash.len() >= 24 && user_hash.chars().all(|c| c.is_ascii_hexdigit()) { user_hash.to_ascii_lowercase() } else { @@ -2060,8 +2031,6 @@ fn ensure_server_tenant( let tenant = tenant_schema(MYSQL_SCHEMA, &prefix)?; let legacy_path = legacy_path.clone(); runtime_wait(runtime, async move { - // Serialize first-time provisioning per account across cluster nodes. - // MySQL named locks are session-scoped and are released automatically if the connection dies. let lock_name = format!("rustbox:{prefix}"); let mut lock_conn = pool .acquire() @@ -2210,10 +2179,8 @@ fn ensure_server_tenant( ConfiguredDatabase::Sqlite { .. } => false, }; - if imported_legacy { - if let Some(path) = legacy_path.as_ref() { - backup_legacy_sqlite(path); - } + if imported_legacy && let Some(path) = legacy_path.as_ref() { + backup_legacy_sqlite(path); } Ok(()) } diff --git a/src/queries/mod.rs b/src/queries/mod.rs index fbc104e..e90e8d4 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -1,6 +1,3 @@ -// Query catalogs intentionally expose parallel SQL dialects. Not every catalog -// is used in a SQLite-only process, so dead-code suppression is scoped to the -// dialect modules instead of the rest of the crate. #[allow(dead_code)] pub mod mysql; #[allow(dead_code)] diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs index 8d7b629..b60bb00 100644 --- a/src/routes/attachments.rs +++ b/src/routes/attachments.rs @@ -194,8 +194,6 @@ pub async fn delete_attachment( return Err(AppError::NotFound("Attachment not found".to_string())); } if let Err(error) = storage.delete(&storage_key).await { - // SQL is authoritative. A failed physical delete leaves an orphaned blob, - // which is safer than leaving a DB row pointing at a missing attachment. tracing::warn!(error = %error, storage_key = %storage_key, "Failed to remove deleted attachment blob"); } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 2bfbf88..f503091 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -223,6 +223,7 @@ fn s3_storage(storage: &StorageConfig) -> Result, String> { .filter(|value| !value.is_empty()) .ok_or_else(|| "storage.bucket is required for S3".to_string())?; let client_options = ClientOptions::new() + .with_allow_http(storage.allow_http) .with_connect_timeout(Duration::from_secs(storage.connect_timeout_secs)) .with_timeout(Duration::from_secs(storage.request_timeout_secs)); let retry = RetryConfig { @@ -234,7 +235,6 @@ fn s3_storage(storage: &StorageConfig) -> Result, String> { .with_bucket_name(bucket) .with_region(storage.region.trim()) .with_virtual_hosted_style_request(!storage.path_style) - .with_allow_http(storage.allow_http) .with_client_options(client_options) .with_retry(retry) .with_checksum_algorithm(Checksum::SHA256); @@ -470,7 +470,6 @@ pub async fn migrate_attachments( ) { Ok(path) => path, Err(error) => { - // A logical DB key may already point at the configured destination after a previous run. if attachment.storage_key == target_key && destination.exists(&target_key).await? { @@ -583,6 +582,13 @@ pub async fn migrate_attachments( mod tests { use super::*; use crate::config::AppConfig; + use axum::{ + Router, + body::Body, + extract::State, + http::{Response, StatusCode, header}, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::TempDir; #[tokio::test] @@ -621,6 +627,66 @@ mod tests { assert!(!storage.exists(&key).await.expect("missing")); } + async fn s3_http_probe(State(hits): State>) -> Response { + hits.fetch_add(1, Ordering::SeqCst); + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::OK; + response + .headers_mut() + .insert(header::CONTENT_LENGTH, "0".parse().expect("content length")); + response.headers_mut().insert( + header::LAST_MODIFIED, + "Sat, 31 Oct 2026 20:00:00 GMT" + .parse() + .expect("last modified"), + ); + response + .headers_mut() + .insert(header::ETAG, "\"rustbox-test\"".parse().expect("etag")); + response + } + + #[tokio::test] + async fn s3_http_endpoint_works_when_explicitly_allowed() { + let hits = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .fallback(s3_http_probe) + .with_state(hits.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test S3 endpoint"); + let endpoint = format!( + "http://{}", + listener.local_addr().expect("test S3 endpoint address") + ); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve test S3 endpoint"); + }); + + let storage = StorageConfig { + backend: "s3".to_string(), + bucket: Some("rustbox-test".to_string()), + region: "us-east-1".to_string(), + endpoint: Some(endpoint), + path_style: true, + allow_http: true, + access_key_id: Some("test-access-key".to_string()), + secret_access_key: Some("test-secret-key".to_string()), + max_retries: 0, + request_timeout_secs: 2, + connect_timeout_secs: 2, + retry_timeout_secs: 2, + ..StorageConfig::default() + }; + + let backend = s3_storage(&storage).expect("S3 backend with HTTP enabled"); + assert!(backend.exists("probe").await.expect("HTTP S3 HEAD request")); + assert_eq!(hits.load(Ordering::SeqCst), 1); + server.abort(); + } + #[test] fn attachment_keys_are_logical_and_path_safe() { assert_eq!( diff --git a/src/web/index.html b/src/web/index.html index d7b2331..2010610 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -567,7 +567,8 @@ data-i18n="filters">Filters + data-i18n="about">About
@@ -943,7 +944,8 @@
-
Configured identitiesSelect an +
Configured identitiesSelect an identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.
@@ -1165,8 +1167,10 @@
-
DefaultsChoose where this signature +
DefaultsChoose where this signature is inserted and, when OpenPGP is configured, its default behavior.