feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
mod edit_file;
|
||||
mod grep_files;
|
||||
mod insert_at_line;
|
||||
mod list_files;
|
||||
mod read_file;
|
||||
mod replace_lines;
|
||||
mod search_file;
|
||||
mod write_file;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Extracts the `path` argument as an owned string, if present. Single-file
|
||||
/// tools use this to advertise their target to the UI via `Tool::target_path`,
|
||||
/// keeping the argument name in one place.
|
||||
pub(crate) fn path_arg(args: &Value) -> Option<String> {
|
||||
args.get("path").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
pub use edit_file::EditFile;
|
||||
pub use grep_files::GrepFiles;
|
||||
pub use insert_at_line::InsertAtLine;
|
||||
pub use list_files::ListFiles;
|
||||
pub use read_file::ReadFile;
|
||||
pub use replace_lines::ReplaceLines;
|
||||
pub use search_file::SearchFile;
|
||||
pub use write_file::WriteFile;
|
||||
|
||||
/// Resolve a user-supplied path:
|
||||
/// - starts with `/` → absolute path, used as-is
|
||||
/// - otherwise → relative to the process working directory (project root)
|
||||
pub fn resolve(user_path: &str) -> Result<PathBuf> {
|
||||
let p = PathBuf::from(user_path);
|
||||
if p.is_absolute() {
|
||||
Ok(p)
|
||||
} else {
|
||||
let cwd = std::env::current_dir()
|
||||
.context("Failed to read current working directory")?;
|
||||
Ok(cwd.join(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `path` (relative entries against `base`) to an absolute, canonical form
|
||||
/// suitable for security prefix-matching. `.`/`..` are resolved and symlinks in the
|
||||
/// existing portion of the path are followed: the longest existing ancestor is
|
||||
/// canonicalized via the OS, and any not-yet-existing tail (e.g. a write target that
|
||||
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
|
||||
/// when nothing along the path can be canonicalized.
|
||||
///
|
||||
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow
|
||||
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
|
||||
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
||||
let raw = {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
|
||||
};
|
||||
let cleaned = lexical_normalize(&raw);
|
||||
|
||||
// Longest existing ancestor first (ancestors() yields self, then parents).
|
||||
for ancestor in cleaned.ancestors() {
|
||||
if let Ok(canon) = std::fs::canonicalize(ancestor) {
|
||||
// `canon.join("")` appends a trailing separator, so skip the join
|
||||
// when the tail is empty (the common case: the file itself is its
|
||||
// first canonicalizable ancestor). Otherwise the canonical path
|
||||
// ends in '/', leaking into display strings and /api/file requests.
|
||||
return match cleaned.strip_prefix(ancestor) {
|
||||
Ok(tail) if !tail.as_os_str().is_empty() => canon.join(tail),
|
||||
_ => canon,
|
||||
};
|
||||
}
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Pure lexical normalization: resolves `.` and `..` components without touching the
|
||||
/// filesystem. Used as the base for `canonicalize_for_policy` and as its fallback.
|
||||
fn lexical_normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for comp in p.components() {
|
||||
match comp {
|
||||
Component::ParentDir => { out.pop(); }
|
||||
Component::CurDir => {}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True if `child` is `base` itself or lies inside it. Both should already be canonical
|
||||
/// (e.g. produced by `canonicalize_for_policy`). Comparison is component-wise, so
|
||||
/// `/a/bc` is not considered to be under `/a/b`.
|
||||
pub fn path_under(child: &Path, base: &Path) -> bool {
|
||||
child.starts_with(base)
|
||||
}
|
||||
|
||||
/// Normalize a user path for display in the UI: relative to the project root when the
|
||||
/// file lives inside it, absolute otherwise. Resolves `.`/`..` and symlinks via
|
||||
/// `canonicalize_for_policy` so the same file always yields the same string — keeping
|
||||
/// the file viewer's "already loaded" check and its watcher subscription consistent.
|
||||
pub fn relativize_for_display(user_path: &str) -> String {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let abs = canonicalize_for_policy(user_path, &cwd);
|
||||
let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
|
||||
match abs.strip_prefix(&cwd_canon) {
|
||||
Ok(rel) => rel.to_string_lossy().into_owned(),
|
||||
Err(_) => abs.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_to_string(user_path: &str) -> Result<String> {
|
||||
let abs = resolve(user_path)?;
|
||||
std::fs::read_to_string(&abs)
|
||||
.with_context(|| format!("Cannot read file: {user_path}"))
|
||||
}
|
||||
|
||||
pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
let abs = resolve(user_path)?;
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&abs, content)
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))
|
||||
}
|
||||
|
||||
pub fn register_all(registry: &mut ToolRegistry) {
|
||||
registry.register(EditFile::new());
|
||||
registry.register(GrepFiles::new());
|
||||
registry.register(InsertAtLine::new());
|
||||
registry.register(ListFiles::new());
|
||||
registry.register(ReadFile::new());
|
||||
registry.register(ReplaceLines::new());
|
||||
registry.register(SearchFile::new());
|
||||
registry.register(WriteFile::new());
|
||||
}
|
||||
Reference in New Issue
Block a user