hyperdb_mcp/attach.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Registry of attached databases for cross-database queries.
5//!
6//! The primary workspace opened by [`crate::engine::Engine`] is always
7//! addressable under the reserved alias `"local"`. Callers can attach
8//! additional `.hyper` files under user-chosen aliases via
9//! [`AttachRegistry::attach`]; the registry tracks every live attachment
10//! so it can be *replayed* after an [`crate::error::ErrorCode::ConnectionLost`]
11//! auto-reconnect rebuilds the underlying Hyper connection.
12//!
13//! # Future kinds
14//!
15//! [`AttachSource`] is a tagged enum so future remote kinds (TCP to a
16//! standard `hyperd`, gRPC to a Data 360 Hyper) plug in without breaking
17//! the registry API or the MCP tool schemas. Only [`AttachSource::LocalFile`]
18//! is implemented today; the MCP tool layer rejects other `kind` values
19//! with a clear "not yet supported" message.
20//!
21//! # Safety model
22//!
23//! - **Path policy.** `LocalFile` paths must be absolute and
24//! canonicalized (`..` components rejected) so the LLM cannot traverse
25//! outside the filesystem root via relative tricks.
26//! - **Alias policy.** Aliases are validated as strict SQL identifiers
27//! (`[A-Za-z_][A-Za-z0-9_]{0,62}`) and cannot collide with `"local"`.
28//! - **Read-only posture.** Attachments default to read-only. Writable
29//! mode is opt-in and is still subject to the server-level `--read-only`
30//! guard — `--read-only` always wins.
31
32use crate::engine::Engine;
33use crate::error::{ErrorCode, McpError};
34use hyperdb_api::escape_sql_path;
35use serde_json::{json, Value};
36use std::path::PathBuf;
37use std::sync::Mutex;
38use std::time::SystemTime;
39
40/// Alias reserved for the server's primary workspace. Users cannot
41/// attach under this name; `copy_query` treats `target_database: "local"`
42/// the same as the unqualified default.
43pub const LOCAL_ALIAS: &str = "local";
44
45/// Escape `s` as a single-quoted SQL string literal (ANSI: double the
46/// embedded single quotes, nothing else is special). Used for
47/// `SET schema_search_path = '…'`.
48fn sql_string_literal(s: &str) -> String {
49 format!("'{}'", s.replace('\'', "''"))
50}
51
52/// Install the primary workspace as the `schema_search_path` so that
53/// unqualified name resolution keeps routing into the primary after
54/// one or more `ATTACH DATABASE`s have run. Hyper's out-of-the-box
55/// default is `"$single"`, which only works while the connection
56/// has exactly one database — the moment a second is attached,
57/// `"$single"` resolves to nothing and unqualified references start
58/// raising `relation does not exist`. See `docs/attach_search_path.md`
59/// (if added) or the bug-fix PR description for the investigation
60/// trail.
61fn set_primary_search_path(engine: &Engine) -> Result<(), McpError> {
62 let sql = format!(
63 "SET schema_search_path = {}",
64 sql_string_literal(&engine.primary_db_name()),
65 );
66 engine.execute_command(&sql)?;
67 Ok(())
68}
69
70/// Restore the connection's search-path posture when the user-visible
71/// attachment registry transitions back to zero attachments.
72///
73/// - When the engine has the default persistent attachment, leave the
74/// pin in place: even with no user attachments, the persistent DB is
75/// *still attached*, so Hyper's `"$single"` resolution would fail.
76/// We pin explicitly to the ephemeral primary's name.
77/// - When `--ephemeral-only` (no persistent attachment), restore Hyper's
78/// default `"$single"` mode so the connection behaves exactly like a
79/// fresh single-database session.
80fn reset_search_path(engine: &Engine) -> Result<(), McpError> {
81 if engine.has_persistent() {
82 // Re-pin to the primary's name so unqualified resolution keeps
83 // working alongside the ever-present persistent attachment.
84 set_primary_search_path(engine)
85 } else {
86 engine.execute_command("RESET schema_search_path")?;
87 Ok(())
88 }
89}
90
91/// Policy for what [`AttachRegistry::attach`] should do when the
92/// requested `LocalFile` path does not exist. Applies only to the
93/// `local_file` kind today; remote kinds (`tcp`, `grpc`) will ignore it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum OnMissing {
96 /// Return [`ErrorCode::FileNotFound`]. Default; matches the pre-
97 /// existing behavior.
98 #[default]
99 Error,
100 /// Create an empty `.hyper` file at the target path first, then
101 /// attach it. Requires `writable: true` — an empty database that
102 /// the session cannot mutate has no use.
103 Create,
104}
105
106impl OnMissing {
107 /// Parse the MCP tool parameter. `None` and the empty string map to
108 /// [`OnMissing::Error`] so callers can omit the field.
109 ///
110 /// # Errors
111 ///
112 /// Returns [`ErrorCode::InvalidArgument`] when `value` is a non-empty
113 /// string other than `"error"` or `"create"`.
114 pub fn parse(value: Option<&str>) -> Result<Self, McpError> {
115 match value.map(str::trim) {
116 None | Some("" | "error") => Ok(Self::Error),
117 Some("create") => Ok(Self::Create),
118 Some(other) => Err(McpError::new(
119 ErrorCode::InvalidArgument,
120 format!("on_missing must be 'error' or 'create', got '{other}'"),
121 )),
122 }
123 }
124}
125
126/// Where an attached database lives. Kind-tagged so future remote
127/// variants (TCP, gRPC) can slot in without breaking the registry API
128/// or MCP tool schemas.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum AttachSource {
131 /// A `.hyper` file on the local filesystem. Paths are absolute and
132 /// canonicalized before landing in the registry.
133 LocalFile {
134 /// Canonical absolute path to the `.hyper` file.
135 path: PathBuf,
136 },
137 // Future: Tcp { endpoint: String, auth: Option<TcpAuth> },
138 // Future: Grpc { endpoint: String, auth: Option<GrpcAuth> }, // writable always false
139}
140
141impl AttachSource {
142 /// Machine-readable kind tag used in MCP tool params and responses.
143 #[must_use]
144 pub fn kind_str(&self) -> &'static str {
145 match self {
146 Self::LocalFile { .. } => "local_file",
147 }
148 }
149
150 /// JSON shape for `list_attached_databases` / `status`.
151 #[must_use]
152 pub fn to_json(&self) -> Value {
153 match self {
154 Self::LocalFile { path } => json!({
155 "kind": "local_file",
156 "path": path.to_string_lossy(),
157 }),
158 }
159 }
160}
161
162/// One live attachment. Constructed by [`AttachRegistry::attach`] and
163/// returned unchanged until the alias is detached.
164#[derive(Debug, Clone)]
165pub struct AttachedDb {
166 pub alias: String,
167 pub source: AttachSource,
168 pub writable: bool,
169 pub attached_at: SystemTime,
170}
171
172impl AttachedDb {
173 /// JSON shape for `list_attached_databases` / `status`. Timestamp
174 /// is emitted as RFC 3339 so clients don't need to know the
175 /// internal format.
176 #[must_use]
177 pub fn to_json(&self) -> Value {
178 let attached_at = chrono::DateTime::<chrono::Utc>::from(self.attached_at).to_rfc3339();
179 json!({
180 "alias": self.alias,
181 "source": self.source.to_json(),
182 "kind": self.source.kind_str(),
183 "writable": self.writable,
184 "attached_at": attached_at,
185 })
186 }
187}
188
189/// Request shape for [`AttachRegistry::attach`]. Pre-validated by the
190/// MCP tool layer; the registry re-validates defensively because it is
191/// also the entry point for replay.
192#[derive(Debug, Clone)]
193pub struct AttachRequest {
194 pub alias: String,
195 pub source: AttachSource,
196 pub writable: bool,
197 /// What to do when `source` points at a `.hyper` file that does
198 /// not yet exist. [`OnMissing::Error`] (the default) preserves the
199 /// original "file must already exist" contract; [`OnMissing::Create`]
200 /// asks the registry to issue `CREATE DATABASE IF NOT EXISTS` before
201 /// attaching, which requires `writable: true`.
202 pub on_missing: OnMissing,
203}
204
205/// Live set of attachments keyed by alias. Thread-safe via an internal
206/// `Mutex`; all operations are serial, which matches the rest of the
207/// engine's single-connection model.
208///
209/// The registry holds *user-attached* databases — the default persistent
210/// database is attached directly by [`crate::engine::Engine`] and isn't
211/// tracked here. Replay-on-reconnect only re-issues the user attaches;
212/// the engine re-attaches persistent itself when it's reconstructed.
213#[derive(Debug)]
214pub struct AttachRegistry {
215 // Insertion-ordered so replay happens in the same order the user
216 // originally attached — matters if attachment B references objects
217 // that rely on attachment A (not today, but cheap to preserve).
218 inner: Mutex<Vec<AttachedDb>>,
219}
220
221impl Default for AttachRegistry {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227impl AttachRegistry {
228 #[must_use]
229 pub fn new() -> Self {
230 Self {
231 inner: Mutex::new(Vec::new()),
232 }
233 }
234
235 /// Attach a database into the current engine's connection and store
236 /// it in the registry. Caller is responsible for read-only
237 /// enforcement (`--read-only` + `writable: true` combination).
238 ///
239 /// # Errors
240 ///
241 /// - Returns [`ErrorCode::InvalidArgument`] if the alias fails
242 /// [`validate_alias`], if the alias is already in use, or if
243 /// `on_missing=Create` is combined with `writable=false`.
244 /// - Returns [`ErrorCode::FileNotFound`] when `on_missing=Error`
245 /// and the target `.hyper` path does not exist.
246 /// - Returns [`ErrorCode::InternalError`] if the registry mutex is
247 /// poisoned (bubbled up from `AttachRegistry::lock`).
248 /// - Propagates any error from the underlying `ATTACH DATABASE`
249 /// (and the optional `CREATE DATABASE IF NOT EXISTS`) executed
250 /// on the engine's connection — surfaced through the `?` operator
251 /// in the body.
252 pub fn attach(&self, engine: &Engine, mut req: AttachRequest) -> Result<AttachedDb, McpError> {
253 validate_alias(&req.alias)?;
254 // Canonicalize the alias to lowercase before storage so the
255 // registry, the cache key in `Engine::catalog_present_cache`,
256 // and the SQL identifier in `qualified_catalog_in` all agree.
257 // Pre-canonicalization, attach was case-sensitive while the
258 // cache and the persistent-alias check were case-insensitive,
259 // which let `attach("User_DB")` + `detach("user_db")` silently
260 // no-op while the cache stayed populated.
261 req.alias = req.alias.to_ascii_lowercase();
262
263 let mut guard = self.lock()?;
264 if guard.iter().any(|a| a.alias == req.alias) {
265 return Err(McpError::new(
266 ErrorCode::InvalidArgument,
267 format!(
268 "Alias '{}' is already in use. Detach it first or pick a different alias.",
269 req.alias
270 ),
271 ));
272 }
273
274 // Build the ATTACH DATABASE statement. Both the path and the
275 // alias have to be safely quoted — the alias was already
276 // validated to match the SQL identifier regex, but we still
277 // quote it so mixed-case names survive.
278 //
279 // For `OnMissing::Create` we issue `CREATE DATABASE IF NOT
280 // EXISTS` first so the attach step sees a valid file. The
281 // create-then-attach is not a transaction — if ATTACH fails
282 // right after we created the file the file stays behind, which
283 // is intentional: the LLM can retry with a different alias or
284 // inspect/delete the file out-of-band.
285 //
286 // We also latch `file_was_created` here (true iff we actually
287 // ran `CREATE DATABASE` because the target file was missing).
288 // The post-attach `_table_catalog` seeding step consults this
289 // flag so that attaching an *existing* database — even via
290 // `on_missing: create` idempotently — never mutates its
291 // schema.
292 let mut file_was_created = false;
293 let sql = match &req.source {
294 AttachSource::LocalFile { path } => {
295 if !path.exists() {
296 match req.on_missing {
297 OnMissing::Error => {
298 return Err(McpError::new(
299 ErrorCode::FileNotFound,
300 format!(
301 "Attach path does not exist: {}. \
302 Pass on_missing='create' (with writable:true) \
303 to create an empty .hyper file at that path.",
304 path.display()
305 ),
306 ));
307 }
308 OnMissing::Create => {
309 if !req.writable {
310 return Err(McpError::new(
311 ErrorCode::InvalidArgument,
312 "on_missing='create' requires writable:true — \
313 an empty .hyper file that cannot be written to \
314 cannot be populated.",
315 ));
316 }
317 let create_sql = format!(
318 "CREATE DATABASE IF NOT EXISTS {}",
319 escape_sql_path(&path.to_string_lossy()),
320 );
321 engine.execute_command(&create_sql)?;
322 file_was_created = true;
323 }
324 }
325 }
326 format!(
327 "ATTACH DATABASE {path} AS \"{alias}\"",
328 path = escape_sql_path(&path.to_string_lossy()),
329 alias = req.alias.replace('"', "\"\""),
330 )
331 }
332 };
333
334 engine.execute_command(&sql)?;
335
336 // Hyper's default `schema_search_path = "$single"` stops
337 // resolving unqualified names the moment the connection has
338 // more than one attached database. Pin it to the primary's
339 // own name so every tool that issues unqualified SQL
340 // (`describe`, `status`, `_table_catalog` upserts, …) keeps
341 // routing into the primary workspace as if nothing else were
342 // attached.
343 //
344 // If the SET fails we treat the whole attach as failed and
345 // roll back with `DETACH`: succeeding here but leaving
346 // `schema_search_path` unpinned puts the session into a
347 // silently-broken state where unqualified local queries start
348 // erroring, which is far worse than a loud up-front error.
349 if let Err(e) = set_primary_search_path(engine) {
350 let detach_sql = format!("DETACH DATABASE \"{}\"", req.alias.replace('"', "\"\""));
351 if let Err(de) = engine.execute_command(&detach_sql) {
352 tracing::warn!(
353 alias = %req.alias,
354 err = %de.message,
355 "rollback DETACH after schema_search_path failure also failed; \
356 connection is in an inconsistent state — reconnect will clear it",
357 );
358 }
359 return Err(e);
360 }
361
362 // Seed `_table_catalog` into a freshly-created attached
363 // database so opening that file as a primary workspace later
364 // (on a fresh MCP instance) finds the catalog ready and skips
365 // the backfill sweep. Gated on `file_was_created` only:
366 // attaching an *existing* database must never mutate its
367 // schema, regardless of contents. (`--bare` used to add a
368 // second gate via `seed_catalog_on_create`; that flag was
369 // removed when `--bare` was retired in favor of the uniform
370 // "always seed on create" policy.)
371 //
372 // On failure we roll back the attach to preserve the
373 // all-or-nothing contract: the user asked for "create a new
374 // DB" which implicitly promises a catalog; leaving an
375 // attached-but-unseeded file would silently violate that.
376 if file_was_created {
377 if let Err(e) = crate::table_catalog::ensure_exists_in(engine, Some(&req.alias)) {
378 let detach_sql = format!("DETACH DATABASE \"{}\"", req.alias.replace('"', "\"\""));
379 if let Err(de) = engine.execute_command(&detach_sql) {
380 tracing::warn!(
381 alias = %req.alias,
382 err = %de.message,
383 "rollback DETACH after _table_catalog seed failure also failed; \
384 alias may remain attached until reconnect",
385 );
386 }
387 // Also reset search_path if this was the first
388 // attachment — the SET we just ran is no longer
389 // backed by an attachment.
390 if guard.is_empty() {
391 let _ = reset_search_path(engine);
392 }
393 return Err(e);
394 }
395 }
396
397 let entry = AttachedDb {
398 alias: req.alias,
399 source: req.source,
400 writable: req.writable,
401 attached_at: SystemTime::now(),
402 };
403 guard.push(entry.clone());
404 Ok(entry)
405 }
406
407 /// Detach the alias from the current connection and drop it from
408 /// the registry. Returns `Ok(false)` if the alias was not present.
409 ///
410 /// When the detachment leaves the registry empty, restores the
411 /// connection's default `schema_search_path` so unqualified name
412 /// resolution returns to the single-database mode Hyper uses on a
413 /// fresh connection.
414 ///
415 /// # Errors
416 ///
417 /// - Returns [`ErrorCode::InternalError`] if the registry mutex is
418 /// poisoned.
419 /// - Propagates any error from the `DETACH DATABASE` statement
420 /// executed via `engine.execute_command`. A failure to reset the
421 /// `schema_search_path` afterwards is logged but NOT surfaced as
422 /// an error — the detach itself already succeeded.
423 pub fn detach(&self, engine: &Engine, alias: &str) -> Result<bool, McpError> {
424 // Aliases are stored lowercased (see `attach`); accept any case
425 // from the caller and canonicalize before lookup.
426 let alias = alias.to_ascii_lowercase();
427 let mut guard = self.lock()?;
428 let pos = guard.iter().position(|a| a.alias == alias);
429 let Some(pos) = pos else {
430 return Ok(false);
431 };
432 let sql = format!("DETACH DATABASE \"{}\"", alias.replace('"', "\"\""));
433 engine.execute_command(&sql)?;
434 guard.remove(pos);
435
436 // Back to the fresh-connection posture: let `"$single"` take
437 // over again so we don't leave a stale SET hanging around
438 // that might shadow the primary's real name (for instance if
439 // the user renames the workspace file across sessions).
440 if guard.is_empty() {
441 if let Err(e) = reset_search_path(engine) {
442 tracing::warn!(
443 err = %e.message,
444 "detach succeeded but could not reset schema_search_path; \
445 unqualified queries should still work against the primary",
446 );
447 }
448 }
449 Ok(true)
450 }
451
452 /// Read-only snapshot of the current registry. Order matches the
453 /// insertion order of still-live entries.
454 pub fn list(&self) -> Vec<AttachedDb> {
455 self.lock().map(|g| g.clone()).unwrap_or_default()
456 }
457
458 /// Lookup by alias (case-insensitive). `None` if absent.
459 ///
460 /// Aliases are stored lowercased (see [`AttachRegistry::attach`]),
461 /// so any caller supplying a mixed-case alias still finds the
462 /// stored entry.
463 pub fn get(&self, alias: &str) -> Option<AttachedDb> {
464 let alias = alias.to_ascii_lowercase();
465 self.lock()
466 .ok()
467 .and_then(|g| g.iter().find(|a| a.alias == alias).cloned())
468 }
469
470 /// Re-issue `ATTACH DATABASE` for every tracked entry. Used after
471 /// [`crate::server::HyperMcpServer`]'s `with_engine` rebuilds a
472 /// fresh [`Engine`] following a `ConnectionLost` error.
473 ///
474 /// Attachments that fail to replay (file moved, corrupted, held by
475 /// another process) are dropped from the registry with a WARN log
476 /// so the rest of the session can continue — a single stale entry
477 /// should not poison the whole reconnect path.
478 ///
479 /// # Errors
480 ///
481 /// Returns [`ErrorCode::InternalError`] if the registry mutex is
482 /// poisoned. Per-entry replay failures are logged and swallowed —
483 /// the method only returns `Err` for errors that prevent it from
484 /// running at all.
485 pub fn replay_all(&self, engine: &Engine) -> Result<(), McpError> {
486 let mut guard = self.lock()?;
487 let snapshot = guard.clone();
488 guard.clear();
489
490 for entry in snapshot {
491 let sql = match &entry.source {
492 AttachSource::LocalFile { path } => format!(
493 "ATTACH DATABASE {path} AS \"{alias}\"",
494 path = escape_sql_path(&path.to_string_lossy()),
495 alias = entry.alias.replace('"', "\"\""),
496 ),
497 };
498 match engine.execute_command(&sql) {
499 Ok(_) => guard.push(entry),
500 Err(e) => {
501 tracing::warn!(
502 alias = %entry.alias,
503 err = %e.message,
504 "dropping attachment that failed to replay after reconnect",
505 );
506 }
507 }
508 }
509
510 // Re-pin the search path if at least one attachment survived
511 // the replay. The post-ConnectionLost engine is brand-new so
512 // any previous `SET schema_search_path` is gone.
513 if !guard.is_empty() {
514 if let Err(e) = set_primary_search_path(engine) {
515 tracing::warn!(
516 err = %e.message,
517 "replay_all: could not re-pin schema_search_path after reconnect",
518 );
519 }
520 }
521 Ok(())
522 }
523
524 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Vec<AttachedDb>>, McpError> {
525 self.inner
526 .lock()
527 .map_err(|_| McpError::new(ErrorCode::InternalError, "AttachRegistry lock poisoned"))
528 }
529}
530
531// --- Validators -------------------------------------------------------------
532
533/// Validate a user-supplied alias. Must match `[A-Za-z_][A-Za-z0-9_]{0,62}`
534/// and must not equal [`LOCAL_ALIAS`]. The 63-char cap matches the
535/// `PostgreSQL` identifier limit Hyper inherits.
536///
537/// # Errors
538///
539/// Returns [`ErrorCode::InvalidArgument`] when:
540/// - `alias` equals [`LOCAL_ALIAS`] (case-insensitive).
541/// - `alias` is empty or longer than 63 characters.
542/// - The first character is neither an ASCII letter nor an underscore.
543/// - Any subsequent character is outside `[A-Za-z0-9_]`.
544///
545/// Validation does NOT lowercase the alias — error messages preserve
546/// the user-typed casing. [`AttachRegistry::attach`] canonicalizes the
547/// alias to lowercase before storing it, so all downstream lookups
548/// (registry, catalog presence cache, qualified SQL identifier) agree
549/// on a single form.
550///
551/// # Panics
552///
553/// Does not panic in practice. The `chars.next().unwrap()` is guarded by
554/// the preceding empty-string check, so at least one character is
555/// guaranteed to exist.
556pub fn validate_alias(alias: &str) -> Result<(), McpError> {
557 if alias.eq_ignore_ascii_case(LOCAL_ALIAS) {
558 return Err(McpError::new(
559 ErrorCode::InvalidArgument,
560 format!(
561 "'{LOCAL_ALIAS}' is reserved for the primary workspace and cannot be used as an attach alias."
562 ),
563 ));
564 }
565 if alias.is_empty() || alias.len() > 63 {
566 return Err(McpError::new(
567 ErrorCode::InvalidArgument,
568 "Alias must be 1..=63 characters",
569 ));
570 }
571 let mut chars = alias.chars();
572 let first = chars.next().unwrap();
573 if !(first.is_ascii_alphabetic() || first == '_') {
574 return Err(McpError::new(
575 ErrorCode::InvalidArgument,
576 format!("Alias '{alias}' must start with a letter or underscore"),
577 ));
578 }
579 for c in chars {
580 if !(c.is_ascii_alphanumeric() || c == '_') {
581 return Err(McpError::new(
582 ErrorCode::InvalidArgument,
583 format!(
584 "Alias '{alias}' contains invalid character '{c}'. \
585 Allowed: [A-Za-z_][A-Za-z0-9_]{{0,62}}"
586 ),
587 ));
588 }
589 }
590 Ok(())
591}
592
593/// Validate a `LocalFile` path for the create-if-missing code path.
594///
595/// Looser than [`validate_local_path`] in one respect only: the target
596/// file itself need not exist yet. Everything else — absolute path,
597/// parent must exist, no `..` components after canonicalization — is
598/// enforced identically. Delegates to [`validate_local_path`] when the
599/// file is already present so the two paths produce the same canonical
600/// output.
601///
602/// # Errors
603///
604/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative, has
605/// no parent directory, has no file-name component, or if the
606/// canonicalized parent contains `..` components.
607/// - Returns [`ErrorCode::FileNotFound`] if the parent directory does
608/// not exist (canonicalization fails).
609/// - Delegates to [`validate_local_path`] when the file already exists,
610/// producing the same errors as that function.
611pub fn validate_local_path_for_create(path: &str) -> Result<PathBuf, McpError> {
612 let pb = PathBuf::from(path);
613 if !pb.is_absolute() {
614 return Err(McpError::new(
615 ErrorCode::InvalidArgument,
616 format!(
617 "Attach path '{path}' must be absolute. \
618 Pass a full path to a .hyper file."
619 ),
620 ));
621 }
622 if pb.exists() {
623 return validate_local_path(path);
624 }
625 let parent = pb.parent().ok_or_else(|| {
626 McpError::new(
627 ErrorCode::InvalidArgument,
628 format!("Attach path '{path}' has no parent directory"),
629 )
630 })?;
631 let file_name = pb.file_name().ok_or_else(|| {
632 McpError::new(
633 ErrorCode::InvalidArgument,
634 format!("Attach path '{path}' has no file-name component"),
635 )
636 })?;
637 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
638 McpError::new(
639 ErrorCode::FileNotFound,
640 format!(
641 "Parent directory of attach path '{path}' does not exist: {e}. \
642 Create the directory first or use on_missing='error'."
643 ),
644 )
645 })?;
646 if canonical_parent
647 .components()
648 .any(|c| matches!(c, std::path::Component::ParentDir))
649 {
650 return Err(McpError::new(
651 ErrorCode::InvalidArgument,
652 format!("Attach path '{path}' resolves to a location containing '..' components"),
653 ));
654 }
655 Ok(canonical_parent.join(file_name))
656}
657
658/// Validate a user-supplied file path that must already exist.
659///
660/// Must be absolute, must exist, must canonicalize cleanly with no `..`
661/// components in the result. Returns the canonical path on success.
662///
663/// `kind` is a short label used in error messages (e.g. `"data file"`,
664/// `"export"`, `"chart output"`). For attach paths use [`validate_local_path`]
665/// which uses `"attach path"` as the label.
666///
667/// # Errors
668///
669/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative or if the
670/// canonicalized path contains `..` components.
671/// - Returns [`ErrorCode::FileNotFound`] if `std::fs::canonicalize` fails.
672pub fn validate_input_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
673 let pb = PathBuf::from(path);
674 if !pb.is_absolute() {
675 return Err(McpError::new(
676 ErrorCode::InvalidArgument,
677 format!("{kind} path '{path}' must be absolute"),
678 ));
679 }
680 let canonical = std::fs::canonicalize(&pb).map_err(|e| {
681 McpError::new(
682 ErrorCode::FileNotFound,
683 format!("Cannot resolve {kind} path '{path}': {e}"),
684 )
685 })?;
686 if canonical
687 .components()
688 .any(|c| matches!(c, std::path::Component::ParentDir))
689 {
690 return Err(McpError::new(
691 ErrorCode::InvalidArgument,
692 format!("{kind} path '{path}' resolves to a location containing '..' components"),
693 ));
694 }
695 Ok(canonical)
696}
697
698/// Validate a user-supplied output path that may not yet exist.
699///
700/// Must be absolute. If the file exists, behaves like [`validate_input_path`].
701/// Otherwise the parent directory must exist and canonicalize cleanly.
702///
703/// # Errors
704///
705/// Same shape as [`validate_input_path`]; additionally returns
706/// [`ErrorCode::InvalidArgument`] if the path has no parent or no file-name.
707pub fn validate_output_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
708 let pb = PathBuf::from(path);
709 if !pb.is_absolute() {
710 return Err(McpError::new(
711 ErrorCode::InvalidArgument,
712 format!("{kind} path '{path}' must be absolute"),
713 ));
714 }
715 if pb.exists() {
716 return validate_input_path(path, kind);
717 }
718 let parent = pb.parent().ok_or_else(|| {
719 McpError::new(
720 ErrorCode::InvalidArgument,
721 format!("{kind} path '{path}' has no parent directory"),
722 )
723 })?;
724 let file_name = pb.file_name().ok_or_else(|| {
725 McpError::new(
726 ErrorCode::InvalidArgument,
727 format!("{kind} path '{path}' has no file-name component"),
728 )
729 })?;
730 if !parent.exists() {
731 std::fs::create_dir_all(parent).map_err(|e| {
732 McpError::new(
733 ErrorCode::InternalError,
734 format!("Failed to create parent directory for {kind} path '{path}': {e}"),
735 )
736 })?;
737 }
738 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
739 McpError::new(
740 ErrorCode::FileNotFound,
741 format!("Parent directory of {kind} path '{path}' does not exist: {e}"),
742 )
743 })?;
744 if canonical_parent
745 .components()
746 .any(|c| matches!(c, std::path::Component::ParentDir))
747 {
748 return Err(McpError::new(
749 ErrorCode::InvalidArgument,
750 format!("{kind} path '{path}' resolves to a location containing '..' components"),
751 ));
752 }
753 Ok(canonical_parent.join(file_name))
754}
755
756/// Validate a `LocalFile` path. Must be absolute, must exist, must
757/// canonicalize cleanly with no `..` components in the result. Returns
758/// the canonical path on success.
759///
760/// # Errors
761///
762/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative or if
763/// the canonicalized path contains `..` components (symlink escape).
764/// - Returns [`ErrorCode::FileNotFound`] if `std::fs::canonicalize`
765/// fails — typically because the file does not exist or a parent
766/// directory is not traversable.
767pub fn validate_local_path(path: &str) -> Result<PathBuf, McpError> {
768 let pb = PathBuf::from(path);
769 if !pb.is_absolute() {
770 return Err(McpError::new(
771 ErrorCode::InvalidArgument,
772 format!(
773 "Attach path '{path}' must be absolute. \
774 Pass a full path to a local .hyper file."
775 ),
776 ));
777 }
778 let canonical = std::fs::canonicalize(&pb).map_err(|e| {
779 McpError::new(
780 ErrorCode::FileNotFound,
781 format!("Cannot resolve attach path '{path}': {e}"),
782 )
783 })?;
784 if canonical
785 .components()
786 .any(|c| matches!(c, std::path::Component::ParentDir))
787 {
788 return Err(McpError::new(
789 ErrorCode::InvalidArgument,
790 format!("Attach path '{path}' resolves to a location containing '..' components"),
791 ));
792 }
793 Ok(canonical)
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn alias_accepts_valid_identifiers() {
802 for a in ["src", "_scratch", "data_2024", "A", "alpha_beta_1"] {
803 validate_alias(a).unwrap_or_else(|e| panic!("expected {a:?} to be accepted: {e}"));
804 }
805 }
806
807 #[test]
808 fn alias_rejects_reserved_local() {
809 assert!(matches!(
810 validate_alias("local").unwrap_err().code,
811 ErrorCode::InvalidArgument
812 ));
813 assert!(matches!(
814 validate_alias("LOCAL").unwrap_err().code,
815 ErrorCode::InvalidArgument
816 ));
817 }
818
819 #[test]
820 fn alias_rejects_bad_shapes() {
821 for a in [
822 "",
823 "1abc",
824 "has space",
825 "a-b",
826 "a.b",
827 "a\"b",
828 &"a".repeat(64),
829 ] {
830 let err = validate_alias(a).expect_err(&format!("expected {a:?} to be rejected"));
831 assert_eq!(err.code, ErrorCode::InvalidArgument, "alias={a:?}");
832 }
833 }
834
835 #[test]
836 fn path_rejects_relative() {
837 let err = validate_local_path("relative/path.hyper").unwrap_err();
838 assert_eq!(err.code, ErrorCode::InvalidArgument);
839 }
840
841 #[test]
842 fn path_rejects_missing() {
843 // Build an absolute path that is guaranteed not to exist on any OS.
844 let missing = std::env::temp_dir().join("hyper_mcp_definitely_missing_99999.hyper");
845 let err = validate_local_path(missing.to_str().unwrap()).unwrap_err();
846 assert_eq!(err.code, ErrorCode::FileNotFound);
847 }
848
849 #[test]
850 fn path_canonicalizes_existing_file() {
851 let dir = tempfile::tempdir().unwrap();
852 let file = dir.path().join("sample.hyper");
853 std::fs::write(&file, b"").unwrap();
854 // Construct a path with a `.` component that canonicalization
855 // should flatten.
856 let noisy = dir.path().join(".").join("sample.hyper");
857 let resolved = validate_local_path(noisy.to_str().unwrap()).unwrap();
858 assert_eq!(resolved, std::fs::canonicalize(&file).unwrap());
859 }
860
861 #[test]
862 fn attached_db_to_json_round_trip() {
863 let entry = AttachedDb {
864 alias: "src".into(),
865 source: AttachSource::LocalFile {
866 path: PathBuf::from("/tmp/foo.hyper"),
867 },
868 writable: false,
869 attached_at: SystemTime::UNIX_EPOCH,
870 };
871 let j = entry.to_json();
872 assert_eq!(j["alias"], "src");
873 assert_eq!(j["writable"], false);
874 assert_eq!(j["kind"], "local_file");
875 assert_eq!(j["source"]["kind"], "local_file");
876 assert_eq!(j["source"]["path"], "/tmp/foo.hyper");
877 }
878
879 // -----------------------------------------------------------------
880 // validate_input_path / validate_output_path
881 // -----------------------------------------------------------------
882
883 #[test]
884 fn validate_input_path_rejects_relative() {
885 let err = validate_input_path("relative/path.csv", "data file").unwrap_err();
886 assert_eq!(err.code, ErrorCode::InvalidArgument);
887 assert!(err.message.contains("data file"));
888 }
889
890 #[test]
891 fn validate_input_path_rejects_missing() {
892 // Build a platform-portable absolute path to a nonexistent file.
893 // Hardcoded "/definitely/..." paths are not absolute on Windows
894 // (no drive letter), so they fail the wrong gate.
895 let missing = std::env::temp_dir().join("hyper_mcp_validate_input_missing_99999.csv");
896 let err = validate_input_path(missing.to_str().unwrap(), "data file").unwrap_err();
897 assert_eq!(err.code, ErrorCode::FileNotFound);
898 }
899
900 #[test]
901 fn validate_input_path_accepts_existing_file() {
902 let f = tempfile::NamedTempFile::new().unwrap();
903 let canonical = validate_input_path(f.path().to_str().unwrap(), "data file").unwrap();
904 assert!(canonical.is_absolute());
905 }
906
907 #[test]
908 fn validate_input_path_kind_appears_in_error() {
909 let err = validate_input_path("relative.csv", "iceberg table").unwrap_err();
910 assert!(
911 err.message.contains("iceberg table"),
912 "got: {}",
913 err.message
914 );
915 }
916
917 #[test]
918 fn validate_output_path_rejects_relative() {
919 let err = validate_output_path("relative/out.csv", "export").unwrap_err();
920 assert_eq!(err.code, ErrorCode::InvalidArgument);
921 }
922
923 #[test]
924 fn validate_output_path_accepts_nonexistent_with_existing_parent() {
925 let dir = tempfile::tempdir().unwrap();
926 let target = dir.path().join("does-not-exist-yet.csv");
927 let canonical =
928 validate_output_path(target.to_str().unwrap(), "export").expect("should accept");
929 assert!(canonical.is_absolute());
930 // The returned path should point at our target (canonical parent + name).
931 assert_eq!(canonical.file_name(), target.file_name());
932 }
933
934 #[test]
935 fn validate_output_path_creates_missing_parent() {
936 let parent = std::env::temp_dir().join("hyper_mcp_validate_output_missing_parent_99999");
937 // Clean up from any prior run.
938 let _ = std::fs::remove_dir_all(&parent);
939 assert!(!parent.exists());
940
941 let target = parent.join("out.csv");
942 let canonical =
943 validate_output_path(target.to_str().unwrap(), "export").expect("should create parent");
944 assert!(canonical.is_absolute());
945 assert!(parent.exists(), "parent directory should have been created");
946 // Clean up.
947 let _ = std::fs::remove_dir_all(&parent);
948 }
949
950 #[test]
951 fn validate_output_path_accepts_existing_file() {
952 let f = tempfile::NamedTempFile::new().unwrap();
953 let canonical = validate_output_path(f.path().to_str().unwrap(), "export").unwrap();
954 assert!(canonical.is_absolute());
955 }
956}