feat(cli): merge oxicloud binary and cli

this feature to simplify the creation of only 1 binary for multiple architecture
This commit is contained in:
Edouard Vanbelle
2026-08-28 20:37:36 +02:00
parent 811c356cde
commit 390aa31443
25 changed files with 769 additions and 571 deletions
+406
View File
@@ -0,0 +1,406 @@
//! `migrate` subcommand domain — one-time data migrations.
//!
//! Sqlx schema migrations run automatically at boot via
//! `sqlx::migrate!()` — this domain is reserved for **data** migrations
//! that need explicit operator invocation (data-loss ambiguity, long
//! runtime, or historical schema-drift cleanup).
//!
//! Currently ships one action: `nfc-filenames` — cleans up NFD/NFC
//! filename collisions in databases populated before the June 2026
//! write-time fix at `src/domain/services/path_service.rs::normalize_storage_name`
//! (called from `src/infrastructure/repositories/pg/file_blob_read_repository.rs`
//! during file operations). New installs never need this migration;
//! only pre-June-2026 databases do.
//!
//! Previously lived in a standalone `migrate-nfc-filenames` binary
//! before the v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b.
//! The 149-line body of `main()` moved here as `run_nfc_filenames()`
//! with `env::args()` parsing replaced by clap.
//!
//! Future removal target: v1.0. Databases upgraded through v0.9.0
//! will have run this migration (or been unaffected because they were
//! post-fix installs); by v1.0 no user should still need it. Drop
//! the `NfcFilenames` variant + this module's `run_nfc_filenames()`
//! function together at that point.
use std::env;
use chrono::{DateTime, Utc};
use clap::Subcommand;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::domain::services::path_service::normalize_storage_name;
#[derive(Subcommand)]
pub enum Action {
/// NFC-normalize storage.files.name across the instance.
///
/// Historical cleanup for databases populated before June 2026.
/// New installs (post-`normalize_storage_name` write-time fix)
/// never need this — file operations already write NFC form.
///
/// Collision handling:
/// * No collision → UPDATE row name to NFC.
/// * Same blob content → trash the newer row.
/// * Different content → rename the newer to `{name}.duplicate[-N]`.
///
/// In all collision cases, the surviving (older) row's name is
/// also normalized to NFC.
NfcFilenames {
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::NfcFilenames { dry_run } => run_nfc_filenames(dry_run).await,
}
}
#[derive(Debug, Clone)]
struct FileRow {
id: Uuid,
folder_id: Option<Uuid>,
user_id: Uuid,
name: String,
blob_hash: String,
created_at: DateTime<Utc>,
}
#[derive(Default)]
struct Stats {
scanned: u64,
already_nfc: u64,
normalized_in_place: u64,
deduped_same_content: u64,
renamed_duplicate: u64,
}
async fn run_nfc_filenames(dry_run: bool) -> u8 {
let database_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("migrate nfc-filenames: DATABASE_URL not set");
return 2;
}
};
let pool = match PgPool::connect(&database_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("migrate nfc-filenames: failed to connect to database: {e}");
return 1;
}
};
println!(
"=== NFC filename migration ({}) ===",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
}
);
println!();
let rows = match load_non_trashed_files(&pool).await {
Ok(r) => r,
Err(e) => {
eprintln!("migrate nfc-filenames: initial scan failed: {e}");
return 1;
}
};
println!("Loaded {} non-trashed file rows", rows.len());
println!();
let mut stats = Stats {
scanned: rows.len() as u64,
..Default::default()
};
for row in &rows {
let nfc_name = normalize_storage_name(&row.name);
if nfc_name == row.name {
stats.already_nfc += 1;
continue;
}
// Row is in non-NFC form. Look for a collision in the same
// (folder_id, user_id) scope, including rows that may also
// be non-NFC but happen to normalize to the same NFC value.
let collision = match find_collision(&pool, row, &nfc_name).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"migrate nfc-filenames: collision query failed for {}: {e}",
row.id
);
return 1;
}
};
match collision {
None => {
println!(
"NORMALIZE {} user={} '{}' → '{}'",
row.id, row.user_id, row.name, nfc_name
);
if !dry_run
&& let Err(e) = sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(&nfc_name)
.bind(row.id)
.execute(&pool)
.await
{
eprintln!("migrate nfc-filenames: rename failed for {}: {e}", row.id);
return 1;
}
stats.normalized_in_place += 1;
}
Some(other) => {
// Pick winner/loser by `created_at` — older wins.
let (older, newer) = if row.created_at <= other.created_at {
(row, &other)
} else {
(&other, row)
};
if older.blob_hash == newer.blob_hash {
// Same content → trash the newer; promote older's
// name to NFC if it isn't already.
println!(
"DEDUP newer={} (trash, same blob) older={} user={} hash={}",
newer.id,
older.id,
older.user_id,
&older.blob_hash[..16.min(older.blob_hash.len())]
);
if !dry_run {
if let Err(e) = sqlx::query(
"UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW()
WHERE id = $1",
)
.bind(newer.id)
.execute(&pool)
.await
{
eprintln!("migrate nfc-filenames: trash failed for {}: {e}", newer.id);
return 1;
}
if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await {
eprintln!(
"migrate nfc-filenames: survivor rename failed for {}: {e}",
older.id
);
return 1;
}
}
stats.deduped_same_content += 1;
} else {
// Different content → rename newer to a free
// `{nfc_name}.duplicate[-N]`; promote older to NFC.
let disambiguated = match find_free_duplicate_name(&pool, newer, &nfc_name)
.await
{
Ok(n) => n,
Err(e) => {
eprintln!(
"migrate nfc-filenames: duplicate-name search failed for {}: {e}",
newer.id
);
return 1;
}
};
println!(
"RENAME newer={} (different blob) older={} '{}' → '{}'",
newer.id, older.id, newer.name, disambiguated
);
if !dry_run {
if let Err(e) =
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(&disambiguated)
.bind(newer.id)
.execute(&pool)
.await
{
eprintln!(
"migrate nfc-filenames: disambiguation rename failed for {}: {e}",
newer.id
);
return 1;
}
if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await {
eprintln!(
"migrate nfc-filenames: survivor rename failed for {}: {e}",
older.id
);
return 1;
}
}
stats.renamed_duplicate += 1;
}
}
}
}
println!();
println!("=== Summary ===");
println!(" scanned : {}", stats.scanned);
println!(
" already in NFC : {}",
stats.already_nfc
);
println!(
" normalized in place (no collision) : {}",
stats.normalized_in_place
);
println!(
" dedup-trashed (same content) : {}",
stats.deduped_same_content
);
println!(
" renamed to .duplicate : {}",
stats.renamed_duplicate
);
if dry_run {
println!();
println!("DRY RUN — no rows were written. Re-run without --dry-run to apply.");
}
0
}
async fn load_non_trashed_files(pool: &PgPool) -> Result<Vec<FileRow>, Box<dyn std::error::Error>> {
let raw = sqlx::query(
"SELECT id, folder_id, user_id, name, blob_hash, created_at
FROM storage.files
WHERE NOT is_trashed
ORDER BY created_at",
)
.fetch_all(pool)
.await?;
let mut out = Vec::with_capacity(raw.len());
for r in raw {
out.push(FileRow {
id: r.try_get("id")?,
folder_id: r.try_get("folder_id")?,
user_id: r.try_get("user_id")?,
name: r.try_get("name")?,
blob_hash: r.try_get("blob_hash")?,
created_at: r.try_get("created_at")?,
});
}
Ok(out)
}
/// Looks for a row in the same `(folder_id, user_id)` scope whose
/// CURRENT name equals `nfc_name`, excluding the row being processed.
/// The other row may itself be in non-NFC form whose normalized
/// representation happens to differ from `nfc_name`; the collision
/// check is intentionally based on stored bytes (matching the
/// UNIQUE-index semantics that this migration is repairing).
async fn find_collision(
pool: &PgPool,
row: &FileRow,
nfc_name: &str,
) -> Result<Option<FileRow>, Box<dyn std::error::Error>> {
let result = sqlx::query(
"SELECT id, folder_id, user_id, name, blob_hash, created_at
FROM storage.files
WHERE name = $1
AND user_id = $2
AND ($3::uuid IS NULL AND folder_id IS NULL
OR folder_id = $3::uuid)
AND id <> $4
AND NOT is_trashed
LIMIT 1",
)
.bind(nfc_name)
.bind(row.user_id)
.bind(row.folder_id)
.bind(row.id)
.fetch_optional(pool)
.await?;
Ok(result.map(|r| FileRow {
id: r.get("id"),
folder_id: r.get("folder_id"),
user_id: r.get("user_id"),
name: r.get("name"),
blob_hash: r.get("blob_hash"),
created_at: r.get("created_at"),
}))
}
/// Finds a free name in the form `{nfc_name}.duplicate` or
/// `{nfc_name}.duplicate-N` for `N >= 1`, scoped to the row's
/// `(folder_id, user_id)`. Returns the first candidate that does
/// not currently exist as a non-trashed row.
async fn find_free_duplicate_name(
pool: &PgPool,
row: &FileRow,
nfc_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let mut suffix: u32 = 0;
loop {
let candidate = if suffix == 0 {
format!("{}.duplicate", nfc_name)
} else {
format!("{}.duplicate-{}", nfc_name, suffix)
};
let taken: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM storage.files
WHERE name = $1
AND user_id = $2
AND ($3::uuid IS NULL AND folder_id IS NULL
OR folder_id = $3::uuid)
AND id <> $4
AND NOT is_trashed)",
)
.bind(&candidate)
.bind(row.user_id)
.bind(row.folder_id)
.bind(row.id)
.fetch_one(pool)
.await?;
if !taken {
return Ok(candidate);
}
suffix = suffix.saturating_add(1);
// Safety bound — should never trigger under realistic data.
if suffix > 10_000 {
return Err(format!(
"Exhausted .duplicate-N suffixes for '{}' in scope (user={}, folder_id={:?})",
nfc_name, row.user_id, row.folder_id
)
.into());
}
}
}
/// If the surviving (older) row's stored name is not yet in NFC,
/// UPDATE it now that the collision has been resolved.
async fn normalize_survivor_name(
pool: &PgPool,
survivor: &FileRow,
nfc_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if survivor.name == nfc_name {
return Ok(());
}
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(nfc_name)
.bind(survivor.id)
.execute(pool)
.await?;
Ok(())
}
+102
View File
@@ -0,0 +1,102 @@
//! Operator-tools subcommand tree.
//!
//! Dispatched from `src/main.rs` when the first positional arg matches
//! a known domain (`opaque`, `migrate`, `storage`). Bare `oxicloud` (or
//! oxicloud with legacy top-level flags like `--config`) falls through
//! to server startup — backwards compat with existing Docker CMD lines
//! and systemd units.
//!
//! History: this tree previously lived in a standalone `oxicloud-cli`
//! binary. Folded into the main `oxicloud` binary in v0.9.0 so the
//! release tarball ships one executable. Growth pattern preserved from
//! the old bin's header — see docs/plan/bundled-binary.md § 1b.
//!
//! ## Layout
//!
//! ```text
//! oxicloud <domain> <action> [flags]
//!
//! Domains:
//! opaque OPAQUE aPAKE substrate management
//! setup Print a fresh ServerSetup value for
//! OXICLOUD_AUTH_OPAQUE_SERVER_SETUP
//! reset Clear envelope(s) so silent-migration
//! re-mints under current KSF
//! migrate One-time data migrations
//! nfc-filenames NFC-normalize storage.files.name
//! (pre-June-2026 databases)
//! storage Storage-config repair + crypto helpers (was --select-storage
//! and --fingerprint before v0.9.0 CLI harmonization).
//! select Set the active storage-entry backend in DB
//! fingerprint Print SSH-style fingerprint of an AES-256 key
//! ```
//!
//! Growth pattern: each new domain gets its own module below (e.g.
//! `mod opaque`, `mod migrate`) with a `#[derive(Subcommand)]` enum
//! for its actions and a `run(action) -> u8` entrypoint. Keep
//! each module self-contained so a future extraction is a file move.
//!
//! ## Environment
//!
//! * `DATABASE_URL` — required by any subcommand that talks to the DB
//! (`opaque reset`, `migrate nfc-filenames`); not needed for pure
//! primitive helpers (`opaque setup`). Each subcommand documents its
//! own dependencies.
use clap::{Parser, Subcommand};
pub mod migrate;
pub mod opaque;
pub mod storage;
#[derive(Parser)]
#[command(
name = "oxicloud",
version,
about = "OxiCloud operator toolbox — subcommand entrypoint for \
operational tasks that don't belong in the main server \
binary. Run `oxicloud` (with no subcommand) to start the \
server."
)]
struct Cli {
#[command(subcommand)]
domain: Domain,
}
#[derive(Subcommand)]
enum Domain {
/// OPAQUE aPAKE substrate management (setup, reset).
Opaque {
#[command(subcommand)]
action: opaque::Action,
},
/// One-time data migrations (historical schema/data fixes).
Migrate {
#[command(subcommand)]
action: migrate::Action,
},
/// Storage-config repair + crypto helpers.
Storage {
#[command(subcommand)]
action: storage::Action,
},
}
/// Entrypoint called from `src/main.rs` after it detects a subcommand
/// on argv[1]. Builds a single-threaded tokio runtime — the operator
/// tools don't need multi-thread scheduling and starting a smaller
/// runtime keeps CLI invocations cheap.
pub fn run() -> u8 {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime for CLI");
rt.block_on(async {
let cli = Cli::parse();
match cli.domain {
Domain::Opaque { action } => opaque::run(action).await,
Domain::Migrate { action } => migrate::run(action).await,
Domain::Storage { action } => storage::run(action).await,
}
})
}
+223
View File
@@ -0,0 +1,223 @@
//! `opaque` subcommand domain — OPAQUE aPAKE substrate management.
//!
//! Two actions today:
//! * `setup` — mint a fresh ServerSetup for
//! `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Deployment-time one-off.
//! * `reset` — clear envelope columns so silent-migration re-mints them
//! under the current KSF. Used after KSF rotation.
//!
//! Previously lived in `src/bin/oxicloud-cli.rs::mod opaque` before the
//! v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b.
//! Behaviour is identical; the only change is the invocation form
//! (`oxicloud opaque <action>` instead of `oxicloud-cli opaque <action>`).
use std::env;
use clap::Subcommand;
use sqlx::{PgPool, Row};
use crate::infrastructure::services::opaque_service::OpaqueService;
#[derive(Subcommand)]
pub enum Action {
/// Generate a fresh OPAQUE ServerSetup and print its base64
/// encoding to stdout. Guidance goes to stderr so shell
/// pipelines capture cleanly.
///
/// Run ONCE per deployment; persist the printed value as
/// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value
/// invalidates every user's OPAQUE registration — treat it
/// like your JWT secret.
Setup,
/// Clear the OPAQUE envelope for one user or all users
/// WITHOUT touching password or setting force_password_change.
///
/// Use case: KSF rotation. If you change
/// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes
/// become cryptographically incompatible with the newly
/// published KSF — logins fail with InvalidCredentials.
/// Nulling the envelope columns forces the SPA's `/lookup`
/// to report `hasOpaque: false`, which routes the next login
/// through legacy `/api/auth/login`; silent-migration then
/// mints a fresh envelope under the CURRENT KSF. Passwords
/// are unchanged.
///
/// NOT for forgotten-passphrase recovery — use the admin
/// password-reset endpoint (`PUT /api/admin/users/{id}/password`)
/// which sets a temp password + force_change flag in one shot.
Reset {
/// Email OR username to reset (dispatched on `@` presence,
/// same rule as `POST /api/auth/login`).
#[arg(long, conflicts_with = "all")]
user: Option<String>,
/// Reset every user with an OPAQUE envelope.
#[arg(long, conflicts_with = "user")]
all: bool,
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::Setup => run_setup(),
Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await,
}
}
fn run_setup() -> u8 {
// Match the legacy `opaque-setup` bin's contract:
// - value on stdout, no trailing commentary (pipeline-safe)
// - guidance on stderr
let b64 = OpaqueService::generate_server_setup_b64();
println!("{b64}");
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
0
}
async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> u8 {
// clap enforces `conflicts_with`, but not "at least one of".
// Belt-and-braces check here so the failure is explicit.
if user.is_none() && !all {
eprintln!("opaque reset: pass either --user <id> or --all");
return 2;
}
let database_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("opaque reset: DATABASE_URL not set");
return 2;
}
};
let pool = match PgPool::connect(&database_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("opaque reset: failed to connect to database: {e}");
return 1;
}
};
// Preview the affected row set before writing. Doubles as
// dry-run output and as diagnostics when --user matches nothing.
// Envelope-presence bool lets the operator see which rows had
// an envelope vs which only carry a stale migration mark.
let select_sql = if all {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
ORDER BY email
"#
} else {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#
};
let rows_result = if all {
sqlx::query(select_sql).fetch_all(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(select_sql).bind(ident).fetch_all(&pool).await
};
let rows = match rows_result {
Ok(r) => r,
Err(e) => {
eprintln!("opaque reset: query failed: {e}");
return 1;
}
};
if rows.is_empty() {
if all {
println!("opaque reset: no users have an OPAQUE envelope — nothing to do.");
return 0;
} else {
eprintln!(
"opaque reset: no user matches --user {} — nothing changed.",
user.as_deref().unwrap_or("")
);
return 1;
}
}
println!(
"opaque reset ({}): {} row(s) to affect",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
},
rows.len()
);
for row in &rows {
let id: uuid::Uuid = row.get("id");
let email: String = row.get("email");
let had_envelope: bool = row.get("had_envelope");
println!(
" {} {} {}",
id,
email,
if had_envelope {
"had-envelope"
} else {
"no-envelope-had-migrated-mark"
}
);
}
if dry_run {
return 0;
}
// Actual UPDATE. Kept identical in shape to the SELECT above so
// the planner sees the same query pattern for both. We
// DELIBERATELY do NOT touch password_hash or
// force_password_change_at_next_login — this tool is scoped
// to "the passwords are fine, the envelopes are stale."
let update_sql_all = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
"#;
let update_sql_one = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#;
let write_result = if all {
sqlx::query(update_sql_all).execute(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(update_sql_one).bind(ident).execute(&pool).await
};
let affected = match write_result {
Ok(r) => r.rows_affected(),
Err(e) => {
eprintln!("opaque reset: update failed: {e}");
return 1;
}
};
println!(
"opaque reset: cleared envelope columns on {affected} row(s). \
Users log in with their existing password; silent-migration \
re-mints envelopes under the current KSF on next login."
);
0
}
+157
View File
@@ -0,0 +1,157 @@
//! `storage` subcommand domain — storage-config repair + crypto helpers.
//!
//! Two actions today:
//! * `select <name>` — set `admin_settings.storage.active_backend_name`
//! in the DB to the named entry and exit. Used to unblock boot after
//! renaming or removing a storage entry in `.env` while the DB still
//! points at the old name (the server aborts boot with a pointer to
//! this subcommand when that happens). See
//! `docs/plan/storage-multi-entry.md` § Fallback.
//! * `fingerprint <base64key|->` — print the SSH-style colon-hex
//! fingerprint of a base64-encoded AES-256 key. Matches the
//! `head_key_fp` field the `backend_rotate` job reports on completion
//! and the raw `<key_fp>` field embedded in every v1 blob header — so
//! an admin can pair a key in `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY`
//! with the current on-disk head and safely drop any key whose
//! fingerprint does NOT match the last-successful rotate.
//!
//! Both actions previously lived as top-level flags (`--select-storage`,
//! `--fingerprint`) on the `oxicloud` binary. Moved into the subcommand
//! tree in v0.9.0 for CLI consistency — see docs/plan/bundled-binary.md
//! § 1c. Behaviour is identical.
use std::env;
use std::io::Read;
use clap::Subcommand;
use crate::common::config::{AppConfig, fingerprint_from_base64_key};
use crate::infrastructure::services::entry_backend::persist_active_backend_name;
#[derive(Subcommand)]
pub enum Action {
/// Select the active storage-entry backend. Writes
/// `admin_settings.storage.active_backend_name = <name>` in the DB
/// and exits. Does NOT boot the server. Use to recover from the
/// "boot fails on missing entry" case after renaming or removing a
/// storage entry in `.env`.
///
/// The named entry MUST appear in `OXICLOUD_STORAGE_ENTRIES` — this
/// subcommand re-parses the same env the server would parse at boot,
/// so a successful run guarantees the subsequent boot will find the
/// entry (no drift between the two code paths).
Select {
/// Storage-entry name (must appear in OXICLOUD_STORAGE_ENTRIES).
name: String,
},
/// Print the SSH-style colon-hex fingerprint (16-hex, 8-byte
/// truncation of sha256) of a base64-encoded AES-256 key.
///
/// Matches the `head_key_fp` field the `backend_rotate` job reports
/// on completion, and the raw `<key_fp>` field embedded in every v1
/// blob header. Used to identify which key in
/// `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY` corresponds to the current
/// on-disk head — safe to drop any key whose fingerprint does NOT
/// match the last-successful rotate's `head_key_fp`.
///
/// Pass `-` to read the key from stdin so it never touches shell
/// history:
///
/// ```text
/// echo -n '<base64>' | oxicloud storage fingerprint -
/// ```
Fingerprint {
/// Base64-encoded AES-256 key, or `-` to read the key from stdin.
key: String,
},
}
pub async fn run(action: Action) -> u8 {
match action {
Action::Select { name } => run_select(&name).await,
Action::Fingerprint { key } => run_fingerprint(&key),
}
}
/// Verify `name` is declared in the current env, UPDATE
/// `admin_settings.storage.active_backend_name`, exit.
///
/// Loading AppConfig here re-runs the same env-parse the server does
/// at boot, so a successful `storage select` guarantees a subsequent
/// normal boot will find the entry — no drift between the two code
/// paths.
async fn run_select(name: &str) -> u8 {
let config = AppConfig::from_env();
if config.storage_entries.is_empty() {
eprintln!(
"OXICLOUD_STORAGE_ENTRIES is not set (or synthesised — legacy path). \
`storage select` needs at least one named entry to switch to."
);
return 2;
}
if !config.storage_entries.iter().any(|e| e.name == name) {
let available = config
.storage_entries
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"entry `{name}` is not declared in OXICLOUD_STORAGE_ENTRIES. \
Available: [{available}]"
);
return 2;
}
let db_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!(
"DATABASE_URL not set — `storage select` needs the same DB the server \
would boot on"
);
return 2;
}
};
let pool = match sqlx::PgPool::connect(&db_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("failed to connect to DATABASE_URL: {e}");
return 1;
}
};
if let Err(e) = persist_active_backend_name(&pool, name).await {
eprintln!("failed to write admin_settings.storage.active_backend_name = `{name}`: {e}");
return 1;
}
println!(
"active_backend_name = `{name}` written to admin_settings. Restart the server to switch."
);
0
}
fn run_fingerprint(key: &str) -> u8 {
let key_b64 = if key == "-" {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("failed to read key from stdin: {e}");
return 2;
}
buf.trim().to_string()
} else {
key.to_string()
};
match fingerprint_from_base64_key(&key_b64) {
Ok(fp) => {
println!("{fp}");
0
}
Err(e) => {
eprintln!("storage fingerprint: {e}");
2
}
}
}