inillucent_cli/command/mod.rs
1//! The command table: one array, read by every front end.
2//!
3//! Invariant: **a command exists once.** [`COMMANDS`] holds its name, its
4//! summary, the parameters it takes and the function that runs it, and the
5//! three front ends read that array rather than each carrying a list:
6//!
7//! - `inillucent <verb>` builds its usage and its argument parsing from it;
8//! - `inillucent-mcp` builds `tools/list` and its JSON Schemas from it;
9//! - `inillucent help` prints it.
10//!
11//! `crates/inillucent-compat/tests/tooling/command_parity.rs` fails the build if a
12//! command loses its description, if a parameter loses one, if a command is
13//! hidden from MCP without a stated reason, or if the two surfaces stop naming
14//! the same set. That test is the whole point of the arrangement: this
15//! repository already argues, in `drivers/README.md`, that a capability list
16//! nobody runs decays into a list of claims that were true once, and a command
17//! list is the same kind of claim.
18//!
19//! **Everything here goes through [`crate::shell::Shell`].** The shell is
20//! already an adapter over the public facade, and the 416-case differential
21//! probe covers that path. A command table that reached past it to the engine
22//! would be a second path to the same data, answering slightly differently, and
23//! nobody would find out from the tests that exist.
24
25pub mod outcome;
26pub mod verbs;
27
28use std::path::PathBuf;
29use std::sync::Arc;
30
31use inillucent_driver::vfs::confine::{self, Root};
32use inillucent_driver::Status;
33
34use crate::json::Json;
35use crate::shell::Shell;
36
37pub use outcome::{Column, Failed, Outcome};
38
39/// What kind of value a parameter takes.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Kind {
42 /// A string.
43 Text,
44 /// A whole number.
45 Integer,
46 /// True or false.
47 Boolean,
48 /// An array of SQL values, for binding to `?1`, `?2`, ...
49 Values,
50}
51
52impl Kind {
53 /// Returns the JSON Schema type an MCP client is told to send.
54 pub fn schema_type(self) -> &'static str {
55 match self {
56 Kind::Text => "string",
57 Kind::Integer => "integer",
58 Kind::Boolean => "boolean",
59 Kind::Values => "array",
60 }
61 }
62
63 /// Returns whether a JSON value has this parameter kind.
64 ///
65 /// @param value - the value a client supplied
66 pub fn accepts(self, value: &Json) -> bool {
67 match self {
68 Kind::Text => matches!(value, Json::Text(_)),
69 Kind::Integer => value.integer().is_some(),
70 Kind::Boolean => matches!(value, Json::Bool(_)),
71 // **An array and an object are values too (task-1979, section 8.2,
72 // gap 2, and D15).** A nested array of numbers is a vector and
73 // `{"blob": "<hex>"}` is bytes; neither had a spelling at all, so a
74 // caller binding into a `VECTOR(N)` column or binding a byte string
75 // had to build a hex literal itself. The command line accepted both
76 // once `literal_of` learned them and this did not, so the two
77 // surfaces disagreed about the same JSON.
78 Kind::Values => value.array().is_some_and(|items| {
79 items.iter().all(|item| match item {
80 Json::Null | Json::Bool(_) | Json::Int(_) | Json::Real(_) | Json::Text(_) => {
81 true
82 }
83 Json::Array(numbers) => numbers
84 .iter()
85 .all(|number| matches!(number, Json::Int(_) | Json::Real(_))),
86 Json::Object(fields) => {
87 fields.len() == 1
88 && fields.iter().all(|(name, value)| {
89 name == "blob" && matches!(value, Json::Text(_))
90 })
91 }
92 })
93 }),
94 }
95 }
96}
97
98/// One parameter a command takes.
99#[derive(Debug, Clone, Copy)]
100pub struct Param {
101 /// The name, which is the MCP property name and the CLI's `--name`.
102 pub name: &'static str,
103 /// What kind of value it takes.
104 pub kind: Kind,
105 /// Whether the command refuses without it.
106 pub required: bool,
107 /// Whether it can be given as a bare word on the command line.
108 ///
109 /// At most one positional per command, and it is always the first one: a
110 /// command line with two unnamed arguments is one nobody can read back.
111 pub positional: bool,
112 /// What it is for, in one sentence.
113 ///
114 /// **This is what the model reads.** A parameter whose description says
115 /// "the table" tells an agent nothing it could not guess; one that says
116 /// which name form is expected saves a failed call. The parity test refuses
117 /// an empty one.
118 pub description: &'static str,
119}
120
121/// What a command does to the database, for the two questions that asks.
122///
123/// **Three states rather than two, because `run` is neither** (task-2066
124/// section 4.2, item 26). The flag this replaces answered one question with
125/// one bit and two different callers read it: `--readonly` refuses a command
126/// that writes, and a command that writes creates the file it was pointed at.
127/// `run` drives the shell, so it may do either, and marking it `true` refused
128/// `inillucent --readonly run "SELECT count(*) FROM t;"` and the
129/// `inillucent_run` MCP tool with it - while marking it `false` would stop
130/// `inillucent --db new.rdb run ".read schema.sql"` from making the file.
131///
132/// The shell it drives already refuses a write statement by statement when it
133/// is read only, from `inillucent_driver::readonly::admits`, which is the same
134/// classification `Context::refuse_if_it_writes` and the driver use. So `run`
135/// needs the verb gate to stand aside and let that refusal happen, which is
136/// the third state.
137#[derive(Clone, Copy, PartialEq, Eq, Debug)]
138pub enum Writes {
139 /// It only reads. `--readonly` admits it and it never creates a file.
140 No,
141 /// It changes the database. `--readonly` refuses it by name.
142 Yes,
143 /// It may change the database, and refuses each statement that does.
144 ///
145 /// `--readonly` admits the verb and the shell underneath refuses the
146 /// writes, one statement at a time, with "attempt to write a readonly
147 /// database".
148 PerStatement,
149}
150
151impl Writes {
152 /// Whether `--readonly` refuses this command before it runs.
153 pub fn refused_when_read_only(self) -> bool {
154 self == Writes::Yes
155 }
156
157 /// Whether this command may create the database file it was pointed at.
158 ///
159 /// A read verb does not make the file it was pointed at, so
160 /// `inillucent --db typo.rdb tables` reports a missing database rather
161 /// than leaving an empty one behind. `run` may, because
162 /// `sqlite3 new.db ".read schema.sql"` does.
163 pub fn may_create(self) -> bool {
164 self != Writes::No
165 }
166}
167
168/// One command.
169pub struct Command {
170 /// The verb, as `inillucent <name>` and as `inillucent_<name>`.
171 pub name: &'static str,
172 /// One line, shown in the command list and used as the MCP description.
173 pub summary: &'static str,
174 /// The longer explanation, shown by `inillucent help <name>` and appended
175 /// to the MCP description so a model reads the same thing a person does.
176 pub detail: &'static str,
177 /// What it takes.
178 pub params: &'static [Param],
179 /// Why it is not offered over MCP, when it is not.
180 pub cli_only: Option<&'static str>,
181 /// Whether it can change the database, and what a read only surface does
182 /// about it.
183 pub writes: Writes,
184 /// What it does.
185 pub run: fn(&mut Context, &Arguments) -> Result<Outcome, Failed>,
186}
187
188impl Command {
189 /// Returns this command's parameter of a given name.
190 ///
191 /// @param name - the parameter name
192 pub fn param(&self, name: &str) -> Option<&'static Param> {
193 self.params.iter().find(|param| param.name == name)
194 }
195
196 /// Returns the parameter that may be written without its name.
197 pub fn positional(&self) -> Option<&'static Param> {
198 self.params.iter().find(|param| param.positional)
199 }
200
201 /// Returns the usage line the CLI prints for this command.
202 pub fn usage(&self) -> String {
203 let mut line = format!("inillucent {}", self.name);
204 for param in self.params {
205 let form = match (param.positional, param.required) {
206 (true, true) => format!(" <{}>", param.name),
207 (true, false) => format!(" [{}]", param.name),
208 (false, true) => format!(" --{} <{}>", param.name, param.name),
209 (false, false) => format!(" [--{} <{}>]", param.name, param.name),
210 };
211 line.push_str(&form);
212 }
213 line
214 }
215
216 /// Returns the finite text values a parameter accepts when it has any.
217 ///
218 /// @param name - the parameter name
219 pub fn allowed_values(&self, name: &str) -> Option<&'static [&'static str]> {
220 match (self.name, name) {
221 (_, "output") => Some(&["text", "json"]),
222 ("import", "format") => Some(&["csv", "tabs", "ascii"]),
223 ("export", "format") => Some(&[
224 "csv", "json", "tabs", "markdown", "insert", "quote", "line", "html",
225 ]),
226 _ => None,
227 }
228 }
229}
230
231/// The values a command was given.
232#[derive(Debug, Clone, Default)]
233pub struct Arguments {
234 /// Each name and what was passed under it.
235 values: Vec<(String, Json)>,
236}
237
238impl Arguments {
239 /// Builds an argument set from an MCP `arguments` object.
240 ///
241 /// @param command - the command that declares the accepted arguments
242 /// @param object - the object the client sent
243 pub fn from_json(command: &Command, object: &Json) -> Result<Arguments, Failed> {
244 let Json::Object(pairs) = object else {
245 return Err(Failed::misuse("tool arguments must be an object."));
246 };
247 for (name, value) in pairs {
248 let Some(param) = command.param(name) else {
249 return Err(Failed::misuse(format!(
250 "'{}' has no '{name}' argument.",
251 command.name
252 )));
253 };
254 if !param.kind.accepts(value) {
255 return Err(Failed::misuse(format!(
256 "'{name}' has to be a {}.",
257 param.kind.schema_type()
258 )));
259 }
260 if let Some(allowed) = command.allowed_values(name) {
261 let Some(text) = value.text() else {
262 return Err(Failed::misuse(format!("'{name}' has to be text.")));
263 };
264 if !allowed.contains(&text) {
265 return Err(Failed::misuse(format!(
266 "'{name}' must be one of: {}.",
267 allowed.join(", ")
268 )));
269 }
270 }
271 }
272 for param in command.params {
273 if param.required && !pairs.iter().any(|(name, _)| name == param.name) {
274 return Err(Failed::misuse(format!("'{}' is required.", param.name)));
275 }
276 }
277 Ok(Arguments {
278 values: pairs.clone(),
279 })
280 }
281
282 /// Records one value.
283 ///
284 /// @param name - the parameter
285 /// @param value - what was given
286 pub fn set(&mut self, name: &str, value: Json) {
287 self.values.retain(|(held, _)| held != name);
288 self.values.push((name.to_string(), value));
289 }
290
291 /// Returns what was given under a name.
292 ///
293 /// @param name - the parameter
294 pub fn get(&self, name: &str) -> Option<&Json> {
295 self.values
296 .iter()
297 .find(|(held, _)| held == name)
298 .map(|(_, value)| value)
299 }
300
301 /// Returns a text parameter, if it was given as text.
302 ///
303 /// @param name - the parameter
304 pub fn text(&self, name: &str) -> Option<&str> {
305 self.get(name).and_then(Json::text)
306 }
307
308 /// Returns a text parameter, or the failure for having left it out.
309 ///
310 /// @param name - the parameter
311 pub fn required_text(&self, name: &str) -> Result<&str, Failed> {
312 match self.get(name) {
313 Some(Json::Text(text)) => Ok(text),
314 Some(_) => Err(Failed::misuse(format!("'{name}' has to be a string."))),
315 None => Err(Failed::misuse(format!("'{name}' is required."))),
316 }
317 }
318
319 /// Returns an integer parameter.
320 ///
321 /// @param name - the parameter
322 pub fn integer(&self, name: &str) -> Option<i64> {
323 self.get(name).and_then(Json::integer)
324 }
325
326 /// Returns a boolean parameter, treating an absent one as false.
327 ///
328 /// @param name - the parameter
329 pub fn flag(&self, name: &str) -> bool {
330 self.get(name).and_then(Json::boolean).unwrap_or(false)
331 }
332
333 /// Returns the array a `values` parameter carries.
334 ///
335 /// @param name - the parameter
336 pub fn values(&self, name: &str) -> Vec<Json> {
337 self.get(name)
338 .and_then(Json::array)
339 .map(<[Json]>::to_vec)
340 .unwrap_or_default()
341 }
342}
343
344/// Where a command runs: the database, and the limits placed on it.
345pub struct Context {
346 /// The shell every command drives.
347 shell: Shell,
348 /// The log segments beside the file that its chain cannot reach.
349 ///
350 /// Read once at the open. See [`Context::strays_beside`] for why the moment
351 /// matters.
352 strays: Vec<u64>,
353 /// The file it is open on.
354 path: String,
355 /// Whether a statement that changes anything is refused.
356 readonly: bool,
357 /// The directory outside which no path may be named.
358 ///
359 /// The same [`Root`] the whole process is confined to, so a refusal a
360 /// person reads and a refusal the file system enforces are one decision
361 /// rather than two that can disagree.
362 root: Option<Arc<Root>>,
363 /// How many rows a command hands back when it was not told.
364 pub limit: usize,
365 /// The most rows one call may hand back, when this surface has a ceiling.
366 max_rows: Option<usize>,
367 /// What one command may spend inside the engine.
368 ///
369 /// Different from `max_rows`, and both are needed. `max_rows` bounds what a
370 /// call *hands back*; this bounds what the engine does on the way there, so
371 /// a `SELECT` whose `WHERE` rejects everything after scanning a hundred
372 /// million rows still stops. A row ceiling alone would let that run to the
373 /// end and then report zero rows.
374 limits: inillucent_driver::StatementLimits,
375 /// Whether arming a budget clears the cancellation flag first.
376 ///
377 /// True everywhere but the MCP server, which reads its input on a second
378 /// thread and therefore owns the ordering itself; see
379 /// `budget::arm_as_it_stands` (task-1932, H11).
380 preserve_cancel: std::cell::Cell<bool>,
381 /// The flag that stops whatever this session is running.
382 ///
383 /// **One per session, not one per call (task-1932, H11).** `run` used to
384 /// arm the budget with a fresh `AtomicBool` it dropped on the way out, so
385 /// the flag the executor polled every batch was one nothing else in the
386 /// process had a handle to: `Connection::cancel` in the driver was correct
387 /// and unreachable, and an MCP `notifications/cancelled` had nothing to
388 /// set. Handing out a clone of this is what makes a cancel arriving from
389 /// another thread land on the statement that is running.
390 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
391 /// What to print where a value is null.
392 pub null: String,
393}
394
395/// Whether a command surface may write to the database it opens.
396///
397/// **An enum rather than a bare `bool` (task-1962, A9).** `Context::open(path,
398/// true, root)` at a call site says nothing about what the `true` decides, and
399/// the surface it opens is the one an operator reaches for when they want to be
400/// certain nothing is written.
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub enum OpenMode {
403 /// Statements that write are refused.
404 ReadOnly,
405 /// The ordinary surface.
406 ReadWrite,
407}
408
409impl OpenMode {
410 /// Reads the `--readonly` flag a command surface was invoked with.
411 ///
412 /// @param readonly - whether the flag was given
413 pub fn of(readonly: bool) -> OpenMode {
414 if readonly {
415 OpenMode::ReadOnly
416 } else {
417 OpenMode::ReadWrite
418 }
419 }
420}
421
422impl Context {
423 /// Opens a context on a database.
424 ///
425 /// @param path - the file, or an in-memory name
426 /// @param readonly - whether writes are refused
427 /// @param root - the directory paths are confined to, if any
428 pub fn open(path: &str, mode: OpenMode, root: Option<PathBuf>) -> Result<Context, Failed> {
429 Context::open_for(path, mode, root, true)
430 }
431
432 /// Opens a database for one command, which may or may not make the file.
433 ///
434 /// @param path - the database to open
435 /// @param mode - whether writes are refused
436 /// @param root - the directory every file is confined to, when there is one
437 /// @param may_create - whether this caller is allowed to make the file
438 pub fn open_for(
439 path: &str,
440 mode: OpenMode,
441 root: Option<PathBuf>,
442 may_create: bool,
443 ) -> Result<Context, Failed> {
444 let readonly = mode == OpenMode::ReadOnly;
445 // **The confinement is installed before the first file is opened.**
446 // The database this surface starts on is a path like any other, and
447 // installing the root afterwards would exempt exactly the one path an
448 // operator is most likely to have got wrong. It also puts the root
449 // where the VFS can see it, which is what confines every file the
450 // engine opens later without this module having to name them.
451 let root = match root {
452 None => None,
453 Some(directory) => {
454 confine::confine_process(&directory).map_err(|error| {
455 Failed::said(Status::InvalidState, error.detail().to_string())
456 })?;
457 confine::process_root()
458 }
459 };
460 let opened = match &root {
461 Some(root) => root
462 .admit(path)
463 .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))?
464 .to_string_lossy()
465 .into_owned(),
466 None => path.to_string(),
467 };
468 // **A read verb does not make the file it was pointed at (task-1979,
469 // E2).** `inillucent --db typo.rdb tables` used to create `typo.rdb`,
470 // write a log segment beside it, print an empty table and exit 0 - so a
471 // mistyped path answered "this database has no tables" and left a file
472 // behind that the next command would then open happily.
473 //
474 // **A verb that writes still makes one**, because that is what
475 // `sqlite3 new.db "CREATE TABLE ..."` does and what every script that
476 // sets a database up in one line expects. `Command::writes` is the same
477 // flag `--readonly` refuses on, so the two questions have one answer.
478 if !may_create
479 && !opened.is_empty()
480 && opened != ":memory:"
481 && !std::path::Path::new(&opened).exists()
482 {
483 return Err(Failed::said(
484 Status::NotFound,
485 format!(
486 "there is no database at \"{opened}\". `inillucent create {opened}` makes one."
487 ),
488 ));
489 }
490 // **The engine's own status, not `io` for everything.** A file another
491 // process holds is `busy`, which a caller can act on by retrying; a
492 // file that is not a database of this engine is `corrupt`. Reporting
493 // both as `io` told a script nothing (task-1979, C6).
494 let mut shell = Shell::open_reporting(&opened, readonly).map_err(|error| {
495 let said = Failed::from_engine(&error);
496 Failed::said(
497 said.status,
498 format!("could not open \"{opened}\": {}", said.message),
499 )
500 })?;
501 // **`--root` turns safe mode on, because otherwise it does not confine
502 // anything (task-1979, H1).** A caller that names a directory has said
503 // the program may touch that directory and nothing else; a `.shell` or
504 // `.system` that spawns `cmd /C` walks straight past that, and the
505 // reviewer's run wrote a file outside the root through the `run` verb
506 // and exited 0. The MCP server turns it on whether or not a root was
507 // given - see `Context::refuse_the_world`.
508 shell.safe = root.is_some();
509 // Read once, here, while the chain this open recovered is still the one
510 // on the disk - see `Context::strays_beside`.
511 let reported = shell.recovery();
512 let strays = Context::strays_beside(&opened, reported.last_sequence, reported.last_lsn);
513 Ok(Context {
514 shell,
515 strays,
516 path: opened,
517 readonly,
518 root,
519 limit: 200,
520 max_rows: None,
521 limits: inillucent_driver::StatementLimits::unbounded(),
522 preserve_cancel: std::cell::Cell::new(false),
523 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
524 null: String::new(),
525 })
526 }
527
528 /// Points this context at a different file, reopening only if it moved.
529 ///
530 /// **One open database at a time, and that is a decision rather than a
531 /// simplification.** One file is one buffer pool, and a server holding four
532 /// of them holds four pools whose sizes nobody asked about. A caller that
533 /// alternates pays a reopen; a caller that does not pays nothing.
534 ///
535 /// @param path - the file to use
536 pub fn use_database(&mut self, path: &str) -> Result<(), Failed> {
537 if path == self.path {
538 return Ok(());
539 }
540 let confined = self.confine(path)?;
541 let named = confined.to_string_lossy().into_owned();
542 self.shell.reopen(&named).map_err(|message| {
543 Failed::said(Status::Io, format!("could not open \"{named}\": {message}"))
544 })?;
545 self.path = named;
546 Ok(())
547 }
548
549 /// Refuses every shell command that reaches outside the database.
550 ///
551 /// **Always on over MCP, and there is no flag to turn it off (task-1979,
552 /// H1).** The set is the reference's `-safe`: running a program
553 /// (`.shell`, `.system`), loading a shared library (`.load`), changing the
554 /// working directory (`.cd`), handing a file to whatever the system opens
555 /// it with (`.excel`, `.www`), and writing output through a pipe. An MCP
556 /// server that left it off handed an agent a shell on the host, whatever
557 /// `--root` said - and a child spawned that way inherits the server's
558 /// standard output, which is the JSON-RPC channel, so its output landed in
559 /// the middle of a reply and the client could not match one to its id.
560 ///
561 /// A flag was the alternative and was rejected: an operator who forgets it
562 /// hands an agent a shell, and there is no case for an MCP server with safe
563 /// mode off.
564 pub fn refuse_the_world(&mut self) {
565 self.shell.safe = true;
566 }
567
568 /// Returns what opening this context's database did to it.
569 ///
570 /// See [`Outcome::with_recovery`] for what the command surface does with
571 /// it.
572 pub fn recovery(&self) -> inillucent_driver::Recovery {
573 self.shell.recovery()
574 }
575
576 /// Returns the log segments beside this database that its chain cannot
577 /// reach.
578 ///
579 /// **A file nothing will replay and nothing will remove (task-1979, C9).**
580 /// A segment copied or restored at a sequence past the live one is ignored
581 /// by recovery, which is right - the chain is followed by sequence from the
582 /// meta record and stops at the first gap - and was also never mentioned,
583 /// so it sat beside the database through every open and close.
584 ///
585 /// **Read once, at the open, and the moment matters.** `truncate_after` has
586 /// just deleted every segment above where the chain stopped that it could
587 /// walk to, so what is left above a gap is what nothing will ever reach.
588 /// Asking again later would answer differently for a reason that is not
589 /// damage: this connection rolls a segment on every checkpoint, another
590 /// process rolls its own, and retiring the ones below a new recovery point
591 /// leaves the directory with gaps that are simply the log moving on.
592 ///
593 /// The directory is read here rather than in the engine because
594 /// `inillucent_vfs::Vfs` has no listing and the command surface is where
595 /// reading a directory already happens. What a name means is
596 /// `inillucent_wal::segment::sequence_of_segment_name`, reached through the
597 /// engine's own re-export, so the naming convention stays in the crate that
598 /// writes it.
599 ///
600 /// @param path - the database file
601 /// @param reaches - the sequence this open's recovery stopped in
602 /// @param ended_at - the stream position it stopped at
603 fn strays_beside(path: &str, reaches: u64, ended_at: u64) -> Vec<u64> {
604 let path = std::path::Path::new(path);
605 let (Some(directory), Some(stem)) = (path.parent(), path.file_name()) else {
606 return Vec::new();
607 };
608 let stem = stem.to_string_lossy().into_owned();
609 let Ok(entries) = std::fs::read_dir(directory) else {
610 return Vec::new();
611 };
612 let mut present: Vec<u64> = entries
613 .flatten()
614 .filter_map(|entry| {
615 let name = entry.file_name().to_string_lossy().into_owned();
616 inillucent_driver::log::sequence_of_segment_name(&stem, &name)
617 })
618 .collect();
619 present.sort_unstable();
620 // **Above the chain's end AND holding positions it has already passed.**
621 // The sequence on its own is not enough: another process writing this
622 // same database rolls to the next one and retires the one below it, so
623 // a live log leaves exactly the shape a leftover does - a number above
624 // this connection's own with a gap under it. What tells them apart is
625 // where the records start. A segment a writer rolled to begins at or
626 // above where this chain ended; a copy of an older segment begins
627 // below it, which means nothing above it will ever read it.
628 present.retain(|sequence| {
629 *sequence > reaches
630 && Context::first_record_of(directory, &stem, *sequence)
631 .is_some_and(|first| first < ended_at)
632 });
633 present
634 }
635
636 /// Returns where one segment file's records start.
637 ///
638 /// @param directory - the directory the database is in
639 /// @param stem - the database file's name
640 /// @param sequence - which segment
641 fn first_record_of(directory: &std::path::Path, stem: &str, sequence: u64) -> Option<u64> {
642 let name = format!("{stem}-wal.{sequence:010}");
643 let head = std::fs::read(directory.join(name)).ok()?;
644 inillucent_driver::log::first_lsn_of(&head)
645 }
646
647 /// Returns the strays this context's open found. See
648 /// [`Context::strays_beside`].
649 pub fn stray_log_segments(&self) -> &[u64] {
650 &self.strays
651 }
652
653 /// Returns a handle to this session's cancellation flag.
654 ///
655 /// Setting it stops the statement that is running, at the next batch. It is
656 /// cleared when the next command is armed, so a cancel that arrives between
657 /// two calls belongs to the one that has finished and is discarded rather
658 /// than applied to the one that has not started.
659 pub fn cancel_flag(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
660 std::sync::Arc::clone(&self.cancel)
661 }
662
663 /// Says that this surface clears the cancellation flag itself.
664 ///
665 /// Only `inillucent-mcp` does, because it is the only one that reads its
666 /// input on a second thread. See `budget::arm_as_it_stands`.
667 pub fn preserve_cancellation(&self) {
668 self.preserve_cancel.set(true);
669 }
670
671 /// Returns the shell commands drive.
672 pub fn shell(&mut self) -> &mut Shell {
673 &mut self.shell
674 }
675
676 /// Returns the file this context is open on.
677 pub fn path(&self) -> &str {
678 &self.path
679 }
680
681 /// Returns whether writes are refused.
682 pub fn readonly(&self) -> bool {
683 self.readonly
684 }
685
686 /// Refuses a row count past this surface's ceiling, when it has one.
687 ///
688 /// **The command line has no ceiling and the MCP server does**, which is
689 /// the whole distinction: a person running `inillucent query` against their
690 /// own database and asking for every row is asking for what they want, and
691 /// an agent doing the same thing to a served database is the case `--root`
692 /// and `--readonly` already exist for. Zero means every row and is refused
693 /// where a ceiling is set, because "every row" is precisely the request the
694 /// ceiling is about.
695 ///
696 /// @param asked - the row count the caller wants
697 pub fn cap_rows(&self, asked: usize) -> Result<usize, Failed> {
698 let Some(most) = self.max_rows else {
699 return Ok(asked);
700 };
701 match asked {
702 0 => Err(Failed::said(
703 Status::InvalidState,
704 format!(
705 "limit=0 asks for every row, and this server hands back at most {most}. Ask \
706 for a count, or narrow the query."
707 ),
708 )),
709 asked if asked > most => Err(Failed::said(
710 Status::InvalidState,
711 format!("limit={asked} is past the {most} rows this server hands back."),
712 )),
713 asked => Ok(asked),
714 }
715 }
716
717 /// Sets the ceiling on how many rows one call hands back.
718 ///
719 /// @param most - the ceiling, or `None` for the command line's absence of one
720 pub fn set_max_rows(&mut self, most: Option<usize>) {
721 self.max_rows = most;
722 }
723
724 /// Sets what one command may spend inside the engine.
725 ///
726 /// @param limits - the budget, or `Limits::unbounded` for a command line
727 pub fn set_limits(&mut self, limits: inillucent_driver::StatementLimits) {
728 self.limits = limits;
729 }
730
731 /// Returns what one command on this surface may spend inside the engine.
732 ///
733 /// A verb that runs work outside the executor - a migration reads a remote
734 /// server and writes rows through a second connection - asks so that it can
735 /// put itself under the same ceiling rather than beside it.
736 pub fn limits(&self) -> inillucent_driver::StatementLimits {
737 self.limits.clone()
738 }
739
740 /// Returns whether this surface was confined to a directory.
741 ///
742 /// **Confinement is about reach, not only about paths.** `--root` exists so
743 /// that an MCP server can be handed to an agent without handing it the file
744 /// system, and a verb that dialled a host and a port would be a hole
745 /// straight through it. A command that can reach something other than a
746 /// file asks this and refuses.
747 pub fn confined(&self) -> bool {
748 self.root.is_some()
749 }
750
751 /// Returns a surface over a shell a test already opened.
752 ///
753 /// Here rather than in each test module because `Context`'s fields are
754 /// private to this module, and a test that reached into them would be a
755 /// second definition of what a surface is.
756 ///
757 /// @param shell - the shell to drive
758 /// @param root - the directory to confine to, when there is one
759 #[cfg(test)]
760 pub fn for_test(shell: Shell, root: Option<PathBuf>) -> Context {
761 Context {
762 shell,
763 strays: Vec::new(),
764 path: ":memory:".to_string(),
765 readonly: false,
766 // A test builds its own root rather than installing a process-wide
767 // one: the process root is set once for the life of the process,
768 // and a test that installed it would decide the confinement of
769 // every other test in the binary.
770 root: root.map(|directory| {
771 Arc::new(Root::resolved(confine::resolve_through_links(&directory)))
772 }),
773 limit: 200,
774 max_rows: None,
775 limits: inillucent_driver::StatementLimits::unbounded(),
776 preserve_cancel: std::cell::Cell::new(false),
777 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
778 null: String::new(),
779 }
780 }
781
782 /// Refuses a path outside the root, when a root was set.
783 ///
784 /// **The decision is not made here.** It is made by
785 /// `inillucent_vfs::confine`, which resolves the path through the file
786 /// system rather than reading its text, and which the VFS consults again
787 /// at the moment the file is opened. This method exists so that a person
788 /// reading a refusal is told the path they typed and the directory they
789 /// confined to, neither of which survives as far as the VFS.
790 ///
791 /// The check this replaced compared normalised path text against the root.
792 /// A junction below the root passed it and opened a database outside the
793 /// root.
794 ///
795 /// @param path - the path a caller named
796 pub fn confine(&self, path: &str) -> Result<PathBuf, Failed> {
797 let Some(root) = &self.root else {
798 return Ok(PathBuf::from(path));
799 };
800 root.admit(path)
801 .map_err(|refused| Failed::said(Status::InvalidState, refused.message()))
802 }
803
804 /// Refuses a statement that changes something, when read-only.
805 ///
806 /// **By the statement's class, in one place shared with the driver
807 /// (task-1979, section 5.2).** The check this replaces asked the engine to
808 /// `EXPLAIN` the statement and refused only when the message contained
809 /// "not a read-only statement" - text `compile_explain` never produces for
810 /// an `INSERT`, a write pragma, an `ATTACH` or a `VACUUM INTO`, all of
811 /// which therefore ran and persisted through `--readonly`. The class comes
812 /// from the parser's own `classify_statement`; the pragma lists are in
813 /// `inillucent_driver::readonly` so this and the driver cannot disagree.
814 ///
815 /// This is the layer that gives the caller a good message. The layer that
816 /// makes the property true is the commit path, which refuses a write on a
817 /// connection opened read only whatever reached it.
818 ///
819 /// @param sql - the statement
820 pub fn refuse_if_it_writes(&self, sql: &str) -> Result<(), Failed> {
821 if !self.readonly {
822 return Ok(());
823 }
824 match inillucent_driver::readonly::admits(sql) {
825 true => Ok(()),
826 false => Err(Failed::said(
827 Status::ReadOnly,
828 "this connection is read only, and that statement changes something.",
829 )),
830 }
831 }
832
833 /// Runs shell input, collecting everything it printed.
834 ///
835 /// @param input - the lines, dot commands included
836 pub fn collect_output(&mut self, input: &str) -> String {
837 self.shell.sink = Some(String::new());
838 let lines: Vec<String> = input.lines().map(str::to_string).collect();
839 crate::shell::drive(&mut self.shell, lines.into_iter());
840 self.shell.sink.take().unwrap_or_default()
841 }
842}
843
844/// Returns the command of a given name.
845///
846/// @param name - the verb, with or without the `inillucent_` prefix MCP uses
847pub fn find(name: &str) -> Option<&'static Command> {
848 let bare = name.strip_prefix("inillucent_").unwrap_or(name);
849 // A dash reads better on a command line and an underscore is required in an
850 // MCP tool name, so both spellings find the same command rather than one of
851 // them being a mistake a caller has to learn about.
852 let wanted = bare.replace('-', "_");
853 COMMANDS
854 .iter()
855 .find(|command| command.name.replace('-', "_") == wanted)
856}
857
858/// Runs a command, applying the checks every front end shares.
859///
860/// The order is the whole of it: the database is selected first because a
861/// confinement refusal must happen before anything is opened, the read-only
862/// check is second because a refusal is cheaper than a run, and only then does
863/// the command see its arguments.
864///
865/// @param command - what to run
866/// @param context - where to run it
867/// @param arguments - what it was given
868pub fn run(
869 command: &'static Command,
870 context: &mut Context,
871 arguments: &Arguments,
872) -> Result<Outcome, Failed> {
873 if let Some(path) = arguments.text("db") {
874 context.use_database(path)?;
875 }
876 if command.writes.refused_when_read_only() && context.readonly() {
877 return Err(Failed::said(
878 Status::ReadOnly,
879 format!(
880 "'{}' changes the database, and this is read only.",
881 command.name
882 ),
883 ));
884 }
885 for param in command.params {
886 if param.required && arguments.get(param.name).is_none() {
887 return Err(Failed::misuse(format!(
888 "'{}' needs '{}'. Usage: {}",
889 command.name,
890 param.name,
891 command.usage()
892 )));
893 }
894 }
895 let started = std::time::Instant::now();
896 // **Armed here, which is the one place every command on every surface goes
897 // through.** Arming it inside each verb would be arming it in nineteen
898 // places and forgetting it in the twentieth; arming it in the engine would
899 // put a server's policy inside a library an application also links.
900 let armed = match context.preserve_cancel.get() {
901 true => inillucent_driver::arm_as_it_stands(context.limits.clone(), context.cancel_flag()),
902 false => inillucent_driver::arm(context.limits.clone(), context.cancel_flag()),
903 };
904 let outcome = (command.run)(context, arguments);
905 drop(armed);
906 let mut produced = outcome?;
907 if produced.elapsed_ms == 0.0 {
908 produced.elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
909 }
910 Ok(produced)
911}
912
913/// The parameter every command takes, so a caller can name the file per call.
914const DB: Param = Param {
915 name: "db",
916 kind: Kind::Text,
917 required: false,
918 positional: false,
919 description: "The database file to run against. Defaults to the one this process was started \
920 on, or :memory: for a scratch database that is discarded when the process ends.",
921};
922
923/// The parameter the row-producing commands take.
924const LIMIT: Param = Param {
925 name: "limit",
926 kind: Kind::Integer,
927 required: false,
928 positional: false,
929 description:
930 "How many rows to hand back. The count in 'total' is still exact, and 'more' says \
931 whether anything was cut off. Defaults to 200. Over MCP there is a ceiling \
932 of 10000 rows and 0 (every row) is refused; on the command line there is \
933 neither. A negative number is refused on both.",
934};
935
936/// The parameter that switches between the two renderings.
937///
938/// **Called `output` and not `format`, because `export` already has a
939/// `format`** - and that one means CSV against JSON against Markdown, which is
940/// a different question from whether the *result object* is drawn as a table or
941/// written as JSON. The collision was real rather than theoretical: with both
942/// called `format`, `inillucent export people --format json` was read as "draw
943/// the result object as JSON" and quietly wrote CSV.
944const FORMAT: Param = Param {
945 name: "output",
946 kind: Kind::Text,
947 required: false,
948 positional: false,
949 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.",
950};
951
952mod registry;
953
954pub use registry::COMMANDS;
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959
960 /// Both spellings of a name find the same command.
961 #[test]
962 fn a_name_is_found_either_way() {
963 assert!(find("query").is_some());
964 assert!(find("inillucent_query").is_some());
965 assert_eq!(
966 find("integrity_check").map(|command| command.name),
967 find("integrity-check").map(|command| command.name)
968 );
969 assert!(find("nonsense").is_none());
970 }
971
972 /// Every command has a summary, a detail, and described parameters.
973 #[test]
974 fn every_command_is_described() {
975 for command in COMMANDS {
976 assert!(
977 !command.summary.is_empty(),
978 "{} has no summary",
979 command.name
980 );
981 assert!(!command.detail.is_empty(), "{} has no detail", command.name);
982 for param in command.params {
983 assert!(
984 !param.description.is_empty(),
985 "{}.{} has no description",
986 command.name,
987 param.name
988 );
989 }
990 }
991 }
992
993 /// A command has at most one positional parameter, and it is the first.
994 #[test]
995 fn at_most_one_positional_and_it_comes_first() {
996 for command in COMMANDS {
997 let positions: Vec<usize> = command
998 .params
999 .iter()
1000 .enumerate()
1001 .filter(|(_, param)| param.positional)
1002 .map(|(nth, _)| nth)
1003 .collect();
1004 assert!(positions.len() <= 1, "{} has two positionals", command.name);
1005 if let Some(first) = positions.first() {
1006 assert_eq!(*first, 0, "{}'s positional is not first", command.name);
1007 }
1008 }
1009 }
1010
1011 /// No command's name is repeated.
1012 #[test]
1013 fn names_are_unique() {
1014 let mut seen: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
1015 let total = seen.len();
1016 seen.sort_unstable();
1017 seen.dedup();
1018 assert_eq!(seen.len(), total);
1019 }
1020
1021 /// A confinement refuses a path that climbs out of the root.
1022 ///
1023 /// The root here is a directory that exists, because the service resolves
1024 /// a candidate through the file system and a root that is not there would
1025 /// make every case below pass for the wrong reason.
1026 #[test]
1027 fn confinement_refuses_a_path_that_climbs_out() {
1028 let root = std::env::temp_dir().join("inillucent-cli-confine");
1029 std::fs::create_dir_all(&root).unwrap();
1030 let context = Context {
1031 // A unit test builds its own context and has no directory to read.
1032 strays: Vec::new(),
1033 shell: Shell::open(":memory:").unwrap(),
1034 path: ":memory:".to_string(),
1035 readonly: false,
1036 root: Some(Arc::new(Root::at(&root).unwrap())),
1037 limit: 200,
1038 max_rows: None,
1039 limits: inillucent_driver::StatementLimits::unbounded(),
1040 preserve_cancel: std::cell::Cell::new(false),
1041 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1042 null: String::new(),
1043 };
1044 assert!(context.confine("inner/app.rdb").is_ok());
1045 assert!(context.confine("../outside.rdb").is_err());
1046 // **An absolute path for this platform, not for Windows
1047 // (task-1946, M5).** This was `C:/elsewhere/app.rdb`, which is absolute
1048 // on Windows and a directory named `C:` on Linux - so the case that was
1049 // meant to be "somewhere else entirely" resolved *inside* the root
1050 // there, and the assertion that it is refused failed on the first Linux
1051 // run that ever reached it. Refusing it there would have been the real
1052 // defect: a relative path under the root is exactly what confinement
1053 // allows.
1054 let elsewhere = if cfg!(windows) {
1055 "C:/elsewhere/app.rdb"
1056 } else {
1057 "/elsewhere/app.rdb"
1058 };
1059 assert!(context.confine(elsewhere).is_err());
1060 assert!(context.confine(":memory:").is_ok());
1061 }
1062
1063 /// A path that reaches outside the root through a link is refused, and the
1064 /// refusal names where it landed.
1065 ///
1066 /// The unit-level half of `crates/inillucent-compat/tests/e2e/confinement.rs`:
1067 /// that suite proves the shipped binaries refuse it, and this one proves
1068 /// the message a person reads says which of the two things went wrong.
1069 #[test]
1070 fn a_refusal_through_a_link_names_the_target() {
1071 let base = std::env::temp_dir().join("inillucent-cli-confine-link");
1072 let root = base.join("root");
1073 let outside = base.join("outside");
1074 std::fs::create_dir_all(&root).unwrap();
1075 std::fs::create_dir_all(&outside).unwrap();
1076 let link = root.join("escape");
1077 if !link.exists() {
1078 #[cfg(windows)]
1079 let made = std::process::Command::new("cmd")
1080 .args(["/C", "mklink", "/J"])
1081 .arg(&link)
1082 .arg(&outside)
1083 .output()
1084 .map(|produced| produced.status.success())
1085 .unwrap_or(false);
1086 #[cfg(unix)]
1087 let made = std::os::unix::fs::symlink(&outside, &link).is_ok();
1088 if !made {
1089 inillucent_base::testing::skipping("this machine cannot create a symlink here");
1090 return;
1091 }
1092 }
1093 let context = Context {
1094 // A unit test builds its own context and has no directory to read.
1095 strays: Vec::new(),
1096 shell: Shell::open(":memory:").unwrap(),
1097 path: ":memory:".to_string(),
1098 readonly: false,
1099 root: Some(Arc::new(Root::at(&root).unwrap())),
1100 limit: 200,
1101 max_rows: None,
1102 limits: inillucent_driver::StatementLimits::unbounded(),
1103 preserve_cancel: std::cell::Cell::new(false),
1104 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1105 null: String::new(),
1106 };
1107 let failure = context
1108 .confine("escape/app.rdb")
1109 .expect_err("a link out of the root is refused");
1110 assert!(
1111 failure.message.contains("resolves to"),
1112 "the refusal did not say where the path landed: {}",
1113 failure.message
1114 );
1115 }
1116}