0.2.4
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-31 22:36:28 +01:00
1 parent 2c98b8f925
commit e4aa787e06
27 files changed
+179 -110

No files matched your search

-2
View File
@@ -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);
+2 -35
View File
@@ -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<char> = 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<String, String> {
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(())
}
-3
View File
@@ -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)]
-2
View File
@@ -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");
}
+68 -2
View File
@@ -223,6 +223,7 @@ fn s3_storage(storage: &StorageConfig) -> Result<Arc<dyn BlobStorage>, 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<Arc<dyn BlobStorage>, 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<Arc<AtomicUsize>>) -> Response<Body> {
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!(
+10 -5
View File
@@ -567,7 +567,8 @@
data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics"
type="button">Diagnostics</button><button class="settings-tab" data-tab="account"
type="button">Account</button><button class="settings-tab" data-tab="about" type="button"
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts" type="button">Keyboard shortcuts</button></nav>
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts"
type="button">Keyboard shortcuts</button></nav>
<div class="settings-content">
<section class="settings-panel" data-panel="appearance">
<div class="settings-panel-heading">
@@ -943,7 +944,8 @@
</div><button id="identity-new" class="button primary" type="button">New identity</button>
</div>
<div class="identity-overview">
<div class="identity-overview-hint"><strong>Configured identities</strong><span id="identity-overview-help" class="muted">Select an
<div class="identity-overview-hint"><strong>Configured identities</strong><span
id="identity-overview-help" class="muted">Select an
identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.</span></div>
<div id="identity-list" class="identity-list"></div>
</div>
@@ -1165,8 +1167,10 @@
<div class="settings-panel-heading account-session-heading">
<div>
<h3>Reset settings</h3>
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.</p>
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all settings</button>
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences.
Messages, identities, keys and templates are kept.</p>
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all
settings</button>
</div>
</section>
<section class="settings-panel hidden" data-panel="about">
@@ -1253,7 +1257,8 @@
</div>
</section>
<section class="identity-modal-section identity-modal-options">
<div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help" class="muted">Choose where this signature
<div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help"
class="muted">Choose where this signature
is inserted and, when OpenPGP is configured, its default behavior.</span></div>
<div class="identity-toggle-grid">
<label class="checkbox-row"><input id="identity-signature-new" type="checkbox"><span>Signature on new