pub mod outcome;
pub mod verbs;
use std::path::PathBuf;
use std::sync::Arc;
use inillucent_driver::vfs::confine::{self, Root};
use inillucent_driver::Status;
use crate::json::Json;
use crate::shell::Shell;
pub use outcome::{Column, Failed, Outcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Text,
Integer,
Boolean,
Values,
}
impl Kind {
pub fn schema_type(self) -> &'static str {
match self {
Kind::Text => "string",
Kind::Integer => "integer",
Kind::Boolean => "boolean",
Kind::Values => "array",
}
}
pub fn accepts(self, value: &Json) -> bool {
match self {
Kind::Text => matches!(value, Json::Text(_)),
Kind::Integer => value.integer().is_some(),
Kind::Boolean => matches!(value, Json::Bool(_)),
Kind::Values => value.array().is_some_and(|items| {
items.iter().all(|item| match item {
Json::Null | Json::Bool(_) | Json::Int(_) | Json::Real(_) | Json::Text(_) => {
true
}
Json::Array(numbers) => numbers
.iter()
.all(|number| matches!(number, Json::Int(_) | Json::Real(_))),
Json::Object(fields) => {
fields.len() == 1
&& fields.iter().all(|(name, value)| {
name == "blob" && matches!(value, Json::Text(_))
})
}
})
}),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Param {
pub name: &'static str,
pub kind: Kind,
pub required: bool,
pub positional: bool,
pub description: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Writes {
No,
Yes,
PerStatement,
}
impl Writes {
pub fn refused_when_read_only(self) -> bool {
self == Writes::Yes
}
pub fn may_create(self) -> bool {
self != Writes::No
}
}
pub struct Command {
pub name: &'static str,
pub summary: &'static str,
pub detail: &'static str,
pub params: &'static [Param],
pub cli_only: Option<&'static str>,
pub writes: Writes,
pub run: fn(&mut Context, &Arguments) -> Result<Outcome, Failed>,
}
impl Command {
pub fn param(&self, name: &str) -> Option<&'static Param> {
self.params.iter().find(|param| param.name == name)
}
pub fn positional(&self) -> Option<&'static Param> {
self.params.iter().find(|param| param.positional)
}
pub fn usage(&self) -> String {
let mut line = format!("inillucent {}", self.name);
for param in self.params {
let form = match (param.positional, param.required) {
(true, true) => format!(" <{}>", param.name),
(true, false) => format!(" [{}]", param.name),
(false, true) => format!(" --{} <{}>", param.name, param.name),
(false, false) => format!(" [--{} <{}>]", param.name, param.name),
};
line.push_str(&form);
}
line
}
pub fn allowed_values(&self, name: &str) -> Option<&'static [&'static str]> {
match (self.name, name) {
(_, "output") => Some(&["text", "json"]),
("import", "format") => Some(&["csv", "tabs", "ascii"]),
("export", "format") => Some(&[
"csv", "json", "tabs", "markdown", "insert", "quote", "line", "html",
]),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Arguments {
values: Vec<(String, Json)>,
}
impl Arguments {
pub fn from_json(command: &Command, object: &Json) -> Result<Arguments, Failed> {
let Json::Object(pairs) = object else {
return Err(Failed::misuse("tool arguments must be an object."));
};
for (name, value) in pairs {
let Some(param) = command.param(name) else {
return Err(Failed::misuse(format!(
"'{}' has no '{name}' argument.",
command.name
)));
};
if !param.kind.accepts(value) {
return Err(Failed::misuse(format!(
"'{name}' has to be a {}.",
param.kind.schema_type()
)));
}
if let Some(allowed) = command.allowed_values(name) {
let Some(text) = value.text() else {
return Err(Failed::misuse(format!("'{name}' has to be text.")));
};
if !allowed.contains(&text) {
return Err(Failed::misuse(format!(
"'{name}' must be one of: {}.",
allowed.join(", ")
)));
}
}
}
for param in command.params {
if param.required && !pairs.iter().any(|(name, _)| name == param.name) {
return Err(Failed::misuse(format!("'{}' is required.", param.name)));
}
}
Ok(Arguments {
values: pairs.clone(),
})
}
pub fn set(&mut self, name: &str, value: Json) {
self.values.retain(|(held, _)| held != name);
self.values.push((name.to_string(), value));
}
pub fn get(&self, name: &str) -> Option<&Json> {
self.values
.iter()
.find(|(held, _)| held == name)
.map(|(_, value)| value)
}
pub fn text(&self, name: &str) -> Option<&str> {
self.get(name).and_then(Json::text)
}
pub fn required_text(&self, name: &str) -> Result<&str, Failed> {
match self.get(name) {
Some(Json::Text(text)) => Ok(text),
Some(_) => Err(Failed::misuse(format!("'{name}' has to be a string."))),
None => Err(Failed::misuse(format!("'{name}' is required."))),
}
}
pub fn integer(&self, name: &str) -> Option<i64> {
self.get(name).and_then(Json::integer)
}
pub fn flag(&self, name: &str) -> bool {
self.get(name).and_then(Json::boolean).unwrap_or(false)
}
pub fn values(&self, name: &str) -> Vec<Json> {
self.get(name)
.and_then(Json::array)
.map(<[Json]>::to_vec)
.unwrap_or_default()
}
}
pub struct Context {
shell: Shell,
strays: Vec<u64>,
path: String,
readonly: bool,
root: Option<Arc<Root>>,
pub limit: usize,
max_rows: Option<usize>,
limits: inillucent_driver::StatementLimits,
preserve_cancel: std::cell::Cell<bool>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub null: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OpenMode {
ReadOnly,
ReadWrite,
}
impl OpenMode {
pub fn of(readonly: bool) -> OpenMode {
if readonly {
OpenMode::ReadOnly
} else {
OpenMode::ReadWrite
}
}
}
impl Context {
pub fn open(path: &str, mode: OpenMode, root: Option<PathBuf>) -> Result<Context, Failed> {
Context::open_for(path, mode, root, true)
}
pub fn open_for(
path: &str,
mode: OpenMode,
root: Option<PathBuf>,
may_create: bool,
) -> Result<Context, Failed> {
let readonly = mode == OpenMode::ReadOnly;
let root = match root {
None => None,
Some(directory) => {
confine::confine_process(&directory).map_err(|error| {
Failed::said(Status::InvalidState, error.detail().to_string())
})?;
confine::process_root()
}
};
let opened = match &root {
Some(root) => root
.admit(path)
.map_err(|refused| Failed::said(Status::InvalidState, refused.message()))?
.to_string_lossy()
.into_owned(),
None => path.to_string(),
};
if !may_create
&& !opened.is_empty()
&& opened != ":memory:"
&& !std::path::Path::new(&opened).exists()
{
return Err(Failed::said(
Status::NotFound,
format!(
"there is no database at \"{opened}\". `inillucent create {opened}` makes one."
),
));
}
let mut shell = Shell::open_reporting(&opened, readonly).map_err(|error| {
let said = Failed::from_engine(&error);
Failed::said(
said.status,
format!("could not open \"{opened}\": {}", said.message),
)
})?;
shell.safe = root.is_some();
let reported = shell.recovery();
let strays = Context::strays_beside(&opened, reported.last_sequence, reported.last_lsn);
Ok(Context {
shell,
strays,
path: opened,
readonly,
root,
limit: 200,
max_rows: None,
limits: inillucent_driver::StatementLimits::unbounded(),
preserve_cancel: std::cell::Cell::new(false),
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
null: String::new(),
})
}
pub fn use_database(&mut self, path: &str) -> Result<(), Failed> {
if path == self.path {
return Ok(());
}
let confined = self.confine(path)?;
let named = confined.to_string_lossy().into_owned();
self.shell.reopen(&named).map_err(|message| {
Failed::said(Status::Io, format!("could not open \"{named}\": {message}"))
})?;
self.path = named;
Ok(())
}
pub fn refuse_the_world(&mut self) {
self.shell.safe = true;
}
pub fn recovery(&self) -> inillucent_driver::Recovery {
self.shell.recovery()
}
fn strays_beside(path: &str, reaches: u64, ended_at: u64) -> Vec<u64> {
let path = std::path::Path::new(path);
let (Some(directory), Some(stem)) = (path.parent(), path.file_name()) else {
return Vec::new();
};
let stem = stem.to_string_lossy().into_owned();
let Ok(entries) = std::fs::read_dir(directory) else {
return Vec::new();
};
let mut present: Vec<u64> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
inillucent_driver::log::sequence_of_segment_name(&stem, &name)
})
.collect();
present.sort_unstable();
present.retain(|sequence| {
*sequence > reaches
&& Context::first_record_of(directory, &stem, *sequence)
.is_some_and(|first| first < ended_at)
});
present
}
fn first_record_of(directory: &std::path::Path, stem: &str, sequence: u64) -> Option<u64> {
let name = format!("{stem}-wal.{sequence:010}");
let head = std::fs::read(directory.join(name)).ok()?;
inillucent_driver::log::first_lsn_of(&head)
}
pub fn stray_log_segments(&self) -> &[u64] {
&self.strays
}
pub fn cancel_flag(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
std::sync::Arc::clone(&self.cancel)
}
pub fn preserve_cancellation(&self) {
self.preserve_cancel.set(true);
}
pub fn shell(&mut self) -> &mut Shell {
&mut self.shell
}
pub fn path(&self) -> &str {
&self.path
}
pub fn readonly(&self) -> bool {
self.readonly
}
pub fn cap_rows(&self, asked: usize) -> Result<usize, Failed> {
let Some(most) = self.max_rows else {
return Ok(asked);
};
match asked {
0 => Err(Failed::said(
Status::InvalidState,
format!(
"limit=0 asks for every row, and this server hands back at most {most}. Ask \
for a count, or narrow the query."
),
)),
asked if asked > most => Err(Failed::said(
Status::InvalidState,
format!("limit={asked} is past the {most} rows this server hands back."),
)),
asked => Ok(asked),
}
}
pub fn set_max_rows(&mut self, most: Option<usize>) {
self.max_rows = most;
}
pub fn set_limits(&mut self, limits: inillucent_driver::StatementLimits) {
self.limits = limits;
}
pub fn limits(&self) -> inillucent_driver::StatementLimits {
self.limits.clone()
}
pub fn confined(&self) -> bool {
self.root.is_some()
}
#[cfg(test)]
pub fn for_test(shell: Shell, root: Option<PathBuf>) -> Context {
Context {
shell,
strays: Vec::new(),
path: ":memory:".to_string(),
readonly: false,
root: root.map(|directory| {
Arc::new(Root::resolved(confine::resolve_through_links(&directory)))
}),
limit: 200,
max_rows: None,
limits: inillucent_driver::StatementLimits::unbounded(),
preserve_cancel: std::cell::Cell::new(false),
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
null: String::new(),
}
}
pub fn confine(&self, path: &str) -> Result<PathBuf, Failed> {
let Some(root) = &self.root else {
return Ok(PathBuf::from(path));
};
root.admit(path)
.map_err(|refused| Failed::said(Status::InvalidState, refused.message()))
}
pub fn refuse_if_it_writes(&self, sql: &str) -> Result<(), Failed> {
if !self.readonly {
return Ok(());
}
match inillucent_driver::readonly::admits(sql) {
true => Ok(()),
false => Err(Failed::said(
Status::ReadOnly,
"this connection is read only, and that statement changes something.",
)),
}
}
pub fn collect_output(&mut self, input: &str) -> String {
self.shell.sink = Some(String::new());
let lines: Vec<String> = input.lines().map(str::to_string).collect();
crate::shell::drive(&mut self.shell, lines.into_iter());
self.shell.sink.take().unwrap_or_default()
}
}
pub fn find(name: &str) -> Option<&'static Command> {
let bare = name.strip_prefix("inillucent_").unwrap_or(name);
let wanted = bare.replace('-', "_");
COMMANDS
.iter()
.find(|command| command.name.replace('-', "_") == wanted)
}
pub fn run(
command: &'static Command,
context: &mut Context,
arguments: &Arguments,
) -> Result<Outcome, Failed> {
if let Some(path) = arguments.text("db") {
context.use_database(path)?;
}
if command.writes.refused_when_read_only() && context.readonly() {
return Err(Failed::said(
Status::ReadOnly,
format!(
"'{}' changes the database, and this is read only.",
command.name
),
));
}
for param in command.params {
if param.required && arguments.get(param.name).is_none() {
return Err(Failed::misuse(format!(
"'{}' needs '{}'. Usage: {}",
command.name,
param.name,
command.usage()
)));
}
}
let started = std::time::Instant::now();
let armed = match context.preserve_cancel.get() {
true => inillucent_driver::arm_as_it_stands(context.limits.clone(), context.cancel_flag()),
false => inillucent_driver::arm(context.limits.clone(), context.cancel_flag()),
};
let outcome = (command.run)(context, arguments);
drop(armed);
let mut produced = outcome?;
if produced.elapsed_ms == 0.0 {
produced.elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
}
Ok(produced)
}
const DB: Param = Param {
name: "db",
kind: Kind::Text,
required: false,
positional: false,
description: "The database file to run against. Defaults to the one this process was started \
on, or :memory: for a scratch database that is discarded when the process ends.",
};
const LIMIT: Param = Param {
name: "limit",
kind: Kind::Integer,
required: false,
positional: false,
description:
"How many rows to hand back. The count in 'total' is still exact, and 'more' says \
whether anything was cut off. Defaults to 200. Over MCP there is a ceiling \
of 10000 rows and 0 (every row) is refused; on the command line there is \
neither. A negative number is refused on both.",
};
const FORMAT: Param = Param {
name: "output",
kind: Kind::Text,
required: false,
positional: false,
description: "'text' for an aligned table a person reads, or 'json' for the whole result object, with typed values, exact counts and the failure class. Defaults to text.",
};
mod registry;
pub use registry::COMMANDS;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_found_either_way() {
assert!(find("query").is_some());
assert!(find("inillucent_query").is_some());
assert_eq!(
find("integrity_check").map(|command| command.name),
find("integrity-check").map(|command| command.name)
);
assert!(find("nonsense").is_none());
}
#[test]
fn every_command_is_described() {
for command in COMMANDS {
assert!(
!command.summary.is_empty(),
"{} has no summary",
command.name
);
assert!(!command.detail.is_empty(), "{} has no detail", command.name);
for param in command.params {
assert!(
!param.description.is_empty(),
"{}.{} has no description",
command.name,
param.name
);
}
}
}
#[test]
fn at_most_one_positional_and_it_comes_first() {
for command in COMMANDS {
let positions: Vec<usize> = command
.params
.iter()
.enumerate()
.filter(|(_, param)| param.positional)
.map(|(nth, _)| nth)
.collect();
assert!(positions.len() <= 1, "{} has two positionals", command.name);
if let Some(first) = positions.first() {
assert_eq!(*first, 0, "{}'s positional is not first", command.name);
}
}
}
#[test]
fn names_are_unique() {
let mut seen: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
let total = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), total);
}
#[test]
fn confinement_refuses_a_path_that_climbs_out() {
let root = std::env::temp_dir().join("inillucent-cli-confine");
std::fs::create_dir_all(&root).unwrap();
let context = Context {
strays: Vec::new(),
shell: Shell::open(":memory:").unwrap(),
path: ":memory:".to_string(),
readonly: false,
root: Some(Arc::new(Root::at(&root).unwrap())),
limit: 200,
max_rows: None,
limits: inillucent_driver::StatementLimits::unbounded(),
preserve_cancel: std::cell::Cell::new(false),
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
null: String::new(),
};
assert!(context.confine("inner/app.rdb").is_ok());
assert!(context.confine("../outside.rdb").is_err());
let elsewhere = if cfg!(windows) {
"C:/elsewhere/app.rdb"
} else {
"/elsewhere/app.rdb"
};
assert!(context.confine(elsewhere).is_err());
assert!(context.confine(":memory:").is_ok());
}
#[test]
fn a_refusal_through_a_link_names_the_target() {
let base = std::env::temp_dir().join("inillucent-cli-confine-link");
let root = base.join("root");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let link = root.join("escape");
if !link.exists() {
#[cfg(windows)]
let made = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(&link)
.arg(&outside)
.output()
.map(|produced| produced.status.success())
.unwrap_or(false);
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&outside, &link).is_ok();
if !made {
inillucent_base::testing::skipping("this machine cannot create a symlink here");
return;
}
}
let context = Context {
strays: Vec::new(),
shell: Shell::open(":memory:").unwrap(),
path: ":memory:".to_string(),
readonly: false,
root: Some(Arc::new(Root::at(&root).unwrap())),
limit: 200,
max_rows: None,
limits: inillucent_driver::StatementLimits::unbounded(),
preserve_cancel: std::cell::Cell::new(false),
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
null: String::new(),
};
let failure = context
.confine("escape/app.rdb")
.expect_err("a link out of the root is refused");
assert!(
failure.message.contains("resolves to"),
"the refusal did not say where the path landed: {}",
failure.message
);
}
}