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/// Inverse of [`set_primary_search_path`] — restores Hyper's default
71/// `"$single"` search mode. Called when the registry has just
72/// transitioned back to zero attachments so the connection behaves
73/// exactly like a fresh single-database session.
74fn reset_search_path(engine: &Engine) -> Result<(), McpError> {
75 engine.execute_command("RESET schema_search_path")?;
76 Ok(())
77}
78
79/// Policy for what [`AttachRegistry::attach`] should do when the
80/// requested `LocalFile` path does not exist. Applies only to the
81/// `local_file` kind today; remote kinds (`tcp`, `grpc`) will ignore it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum OnMissing {
84 /// Return [`ErrorCode::FileNotFound`]. Default; matches the pre-
85 /// existing behavior.
86 #[default]
87 Error,
88 /// Create an empty `.hyper` file at the target path first, then
89 /// attach it. Requires `writable: true` — an empty database that
90 /// the session cannot mutate has no use.
91 Create,
92}
93
94impl OnMissing {
95 /// Parse the MCP tool parameter. `None` and the empty string map to
96 /// [`OnMissing::Error`] so callers can omit the field.
97 ///
98 /// # Errors
99 ///
100 /// Returns [`ErrorCode::InvalidArgument`] when `value` is a non-empty
101 /// string other than `"error"` or `"create"`.
102 pub fn parse(value: Option<&str>) -> Result<Self, McpError> {
103 match value.map(str::trim) {
104 None | Some("" | "error") => Ok(Self::Error),
105 Some("create") => Ok(Self::Create),
106 Some(other) => Err(McpError::new(
107 ErrorCode::InvalidArgument,
108 format!("on_missing must be 'error' or 'create', got '{other}'"),
109 )),
110 }
111 }
112}
113
114/// Where an attached database lives. Kind-tagged so future remote
115/// variants (TCP, gRPC) can slot in without breaking the registry API
116/// or MCP tool schemas.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum AttachSource {
119 /// A `.hyper` file on the local filesystem. Paths are absolute and
120 /// canonicalized before landing in the registry.
121 LocalFile {
122 /// Canonical absolute path to the `.hyper` file.
123 path: PathBuf,
124 },
125 // Future: Tcp { endpoint: String, auth: Option<TcpAuth> },
126 // Future: Grpc { endpoint: String, auth: Option<GrpcAuth> }, // writable always false
127}
128
129impl AttachSource {
130 /// Machine-readable kind tag used in MCP tool params and responses.
131 #[must_use]
132 pub fn kind_str(&self) -> &'static str {
133 match self {
134 Self::LocalFile { .. } => "local_file",
135 }
136 }
137
138 /// JSON shape for `list_attached_databases` / `status`.
139 #[must_use]
140 pub fn to_json(&self) -> Value {
141 match self {
142 Self::LocalFile { path } => json!({
143 "kind": "local_file",
144 "path": path.to_string_lossy(),
145 }),
146 }
147 }
148}
149
150/// One live attachment. Constructed by [`AttachRegistry::attach`] and
151/// returned unchanged until the alias is detached.
152#[derive(Debug, Clone)]
153pub struct AttachedDb {
154 pub alias: String,
155 pub source: AttachSource,
156 pub writable: bool,
157 pub attached_at: SystemTime,
158}
159
160impl AttachedDb {
161 /// JSON shape for `list_attached_databases` / `status`. Timestamp
162 /// is emitted as RFC 3339 so clients don't need to know the
163 /// internal format.
164 #[must_use]
165 pub fn to_json(&self) -> Value {
166 let attached_at = chrono::DateTime::<chrono::Utc>::from(self.attached_at).to_rfc3339();
167 json!({
168 "alias": self.alias,
169 "source": self.source.to_json(),
170 "kind": self.source.kind_str(),
171 "writable": self.writable,
172 "attached_at": attached_at,
173 })
174 }
175}
176
177/// Request shape for [`AttachRegistry::attach`]. Pre-validated by the
178/// MCP tool layer; the registry re-validates defensively because it is
179/// also the entry point for replay.
180#[derive(Debug, Clone)]
181pub struct AttachRequest {
182 pub alias: String,
183 pub source: AttachSource,
184 pub writable: bool,
185 /// What to do when `source` points at a `.hyper` file that does
186 /// not yet exist. [`OnMissing::Error`] (the default) preserves the
187 /// original "file must already exist" contract; [`OnMissing::Create`]
188 /// asks the registry to issue `CREATE DATABASE IF NOT EXISTS` before
189 /// attaching, which requires `writable: true`.
190 pub on_missing: OnMissing,
191}
192
193/// Live set of attachments keyed by alias. Thread-safe via an internal
194/// `Mutex`; all operations are serial, which matches the rest of the
195/// engine's single-connection model.
196#[derive(Debug)]
197pub struct AttachRegistry {
198 // Insertion-ordered so replay happens in the same order the user
199 // originally attached — matters if attachment B references objects
200 // that rely on attachment A (not today, but cheap to preserve).
201 inner: Mutex<Vec<AttachedDb>>,
202 /// When `true`, a successful `attach` that just created a fresh
203 /// `.hyper` file via `on_missing: create` also stamps an empty
204 /// `_table_catalog` into the new database (fully qualified as
205 /// `"{alias}"."public"."_table_catalog"`). Exactly mirrors the
206 /// primary-workspace policy in
207 /// [`crate::server::HyperMcpServer::ensure_catalog_ready`]:
208 /// non-bare servers get a catalog on newly-created databases,
209 /// bare servers never touch the file beyond the `ATTACH` itself.
210 /// Attaching an *existing* database never seeds regardless of
211 /// this flag.
212 seed_catalog_on_create: bool,
213}
214
215impl Default for AttachRegistry {
216 fn default() -> Self {
217 Self::with_catalog_policy(true)
218 }
219}
220
221impl AttachRegistry {
222 /// Convenience for tests and the default `HyperMcpServer` flow.
223 /// Seeds `_table_catalog` on newly-created databases (non-bare
224 /// policy). Bare servers must use
225 /// [`AttachRegistry::with_catalog_policy`] with `false` instead.
226 #[must_use]
227 pub fn new() -> Self {
228 Self::default()
229 }
230
231 /// Explicit-policy constructor. `seed_catalog_on_create = false`
232 /// matches `--bare`: the registry will never stamp
233 /// `_table_catalog` into any attached database (neither freshly
234 /// created nor pre-existing).
235 #[must_use]
236 pub fn with_catalog_policy(seed_catalog_on_create: bool) -> Self {
237 Self {
238 inner: Mutex::new(Vec::new()),
239 seed_catalog_on_create,
240 }
241 }
242
243 /// Attach a database into the current engine's connection and store
244 /// it in the registry. Caller is responsible for read-only
245 /// enforcement (`--read-only` + `writable: true` combination).
246 ///
247 /// # Errors
248 ///
249 /// - Returns [`ErrorCode::InvalidArgument`] if the alias fails
250 /// [`validate_alias`], if the alias is already in use, or if
251 /// `on_missing=Create` is combined with `writable=false`.
252 /// - Returns [`ErrorCode::FileNotFound`] when `on_missing=Error`
253 /// and the target `.hyper` path does not exist.
254 /// - Returns [`ErrorCode::InternalError`] if the registry mutex is
255 /// poisoned (bubbled up from `AttachRegistry::lock`).
256 /// - Propagates any error from the underlying `ATTACH DATABASE`
257 /// (and the optional `CREATE DATABASE IF NOT EXISTS`) executed
258 /// on the engine's connection — surfaced through the `?` operator
259 /// in the body.
260 pub fn attach(&self, engine: &Engine, req: AttachRequest) -> Result<AttachedDb, McpError> {
261 validate_alias(&req.alias)?;
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. Intentionally gated on *both*:
366 //
367 // 1. `file_was_created` — attaching an existing database
368 // must never mutate its schema, regardless of contents.
369 // 2. `self.seed_catalog_on_create` — bare servers use
370 // `with_catalog_policy(false)` so the workspace stays
371 // pristine end-to-end.
372 //
373 // On failure we roll back the attach to preserve the
374 // all-or-nothing contract: the user asked for "create a new
375 // DB (non-bare)" which implicitly promises a catalog; leaving
376 // an attached-but-unseeded file would silently violate that.
377 if file_was_created && self.seed_catalog_on_create {
378 if let Err(e) = crate::table_catalog::ensure_exists_in_database(engine, &req.alias) {
379 let detach_sql = format!("DETACH DATABASE \"{}\"", req.alias.replace('"', "\"\""));
380 if let Err(de) = engine.execute_command(&detach_sql) {
381 tracing::warn!(
382 alias = %req.alias,
383 err = %de.message,
384 "rollback DETACH after _table_catalog seed failure also failed; \
385 alias may remain attached until reconnect",
386 );
387 }
388 // Also reset search_path if this was the first
389 // attachment — the SET we just ran is no longer
390 // backed by an attachment.
391 if guard.is_empty() {
392 let _ = reset_search_path(engine);
393 }
394 return Err(e);
395 }
396 }
397
398 let entry = AttachedDb {
399 alias: req.alias,
400 source: req.source,
401 writable: req.writable,
402 attached_at: SystemTime::now(),
403 };
404 guard.push(entry.clone());
405 Ok(entry)
406 }
407
408 /// Detach the alias from the current connection and drop it from
409 /// the registry. Returns `Ok(false)` if the alias was not present.
410 ///
411 /// When the detachment leaves the registry empty, restores the
412 /// connection's default `schema_search_path` so unqualified name
413 /// resolution returns to the single-database mode Hyper uses on a
414 /// fresh connection.
415 ///
416 /// # Errors
417 ///
418 /// - Returns [`ErrorCode::InternalError`] if the registry mutex is
419 /// poisoned.
420 /// - Propagates any error from the `DETACH DATABASE` statement
421 /// executed via `engine.execute_command`. A failure to reset the
422 /// `schema_search_path` afterwards is logged but NOT surfaced as
423 /// an error — the detach itself already succeeded.
424 pub fn detach(&self, engine: &Engine, alias: &str) -> Result<bool, McpError> {
425 let mut guard = self.lock()?;
426 let pos = guard.iter().position(|a| a.alias == alias);
427 let Some(pos) = pos else {
428 return Ok(false);
429 };
430 let sql = format!("DETACH DATABASE \"{}\"", alias.replace('"', "\"\""));
431 engine.execute_command(&sql)?;
432 guard.remove(pos);
433
434 // Back to the fresh-connection posture: let `"$single"` take
435 // over again so we don't leave a stale SET hanging around
436 // that might shadow the primary's real name (for instance if
437 // the user renames the workspace file across sessions).
438 if guard.is_empty() {
439 if let Err(e) = reset_search_path(engine) {
440 tracing::warn!(
441 err = %e.message,
442 "detach succeeded but could not reset schema_search_path; \
443 unqualified queries should still work against the primary",
444 );
445 }
446 }
447 Ok(true)
448 }
449
450 /// Read-only snapshot of the current registry. Order matches the
451 /// insertion order of still-live entries.
452 pub fn list(&self) -> Vec<AttachedDb> {
453 self.lock().map(|g| g.clone()).unwrap_or_default()
454 }
455
456 /// Lookup by alias. `None` if absent.
457 pub fn get(&self, alias: &str) -> Option<AttachedDb> {
458 self.lock()
459 .ok()
460 .and_then(|g| g.iter().find(|a| a.alias == alias).cloned())
461 }
462
463 /// Re-issue `ATTACH DATABASE` for every tracked entry. Used after
464 /// [`crate::server::HyperMcpServer`]'s `with_engine` rebuilds a
465 /// fresh [`Engine`] following a `ConnectionLost` error.
466 ///
467 /// Attachments that fail to replay (file moved, corrupted, held by
468 /// another process) are dropped from the registry with a WARN log
469 /// so the rest of the session can continue — a single stale entry
470 /// should not poison the whole reconnect path.
471 ///
472 /// # Errors
473 ///
474 /// Returns [`ErrorCode::InternalError`] if the registry mutex is
475 /// poisoned. Per-entry replay failures are logged and swallowed —
476 /// the method only returns `Err` for errors that prevent it from
477 /// running at all.
478 pub fn replay_all(&self, engine: &Engine) -> Result<(), McpError> {
479 let mut guard = self.lock()?;
480 let snapshot = guard.clone();
481 guard.clear();
482
483 for entry in snapshot {
484 let sql = match &entry.source {
485 AttachSource::LocalFile { path } => format!(
486 "ATTACH DATABASE {path} AS \"{alias}\"",
487 path = escape_sql_path(&path.to_string_lossy()),
488 alias = entry.alias.replace('"', "\"\""),
489 ),
490 };
491 match engine.execute_command(&sql) {
492 Ok(_) => guard.push(entry),
493 Err(e) => {
494 tracing::warn!(
495 alias = %entry.alias,
496 err = %e.message,
497 "dropping attachment that failed to replay after reconnect",
498 );
499 }
500 }
501 }
502
503 // Re-pin the search path if at least one attachment survived
504 // the replay. The post-ConnectionLost engine is brand-new so
505 // any previous `SET schema_search_path` is gone.
506 if !guard.is_empty() {
507 if let Err(e) = set_primary_search_path(engine) {
508 tracing::warn!(
509 err = %e.message,
510 "replay_all: could not re-pin schema_search_path after reconnect",
511 );
512 }
513 }
514 Ok(())
515 }
516
517 fn lock(&self) -> Result<std::sync::MutexGuard<'_, Vec<AttachedDb>>, McpError> {
518 self.inner
519 .lock()
520 .map_err(|_| McpError::new(ErrorCode::InternalError, "AttachRegistry lock poisoned"))
521 }
522}
523
524// --- Validators -------------------------------------------------------------
525
526/// Validate a user-supplied alias. Must match `[A-Za-z_][A-Za-z0-9_]{0,62}`
527/// and must not equal [`LOCAL_ALIAS`]. The 63-char cap matches the
528/// `PostgreSQL` identifier limit Hyper inherits.
529///
530/// # Errors
531///
532/// Returns [`ErrorCode::InvalidArgument`] when:
533/// - `alias` equals [`LOCAL_ALIAS`] (case-insensitive).
534/// - `alias` is empty or longer than 63 characters.
535/// - The first character is neither an ASCII letter nor an underscore.
536/// - Any subsequent character is outside `[A-Za-z0-9_]`.
537///
538/// # Panics
539///
540/// Does not panic in practice. The `chars.next().unwrap()` is guarded by
541/// the preceding empty-string check, so at least one character is
542/// guaranteed to exist.
543pub fn validate_alias(alias: &str) -> Result<(), McpError> {
544 if alias.eq_ignore_ascii_case(LOCAL_ALIAS) {
545 return Err(McpError::new(
546 ErrorCode::InvalidArgument,
547 format!(
548 "'{LOCAL_ALIAS}' is reserved for the primary workspace and cannot be used as an attach alias."
549 ),
550 ));
551 }
552 if alias.is_empty() || alias.len() > 63 {
553 return Err(McpError::new(
554 ErrorCode::InvalidArgument,
555 "Alias must be 1..=63 characters",
556 ));
557 }
558 let mut chars = alias.chars();
559 let first = chars.next().unwrap();
560 if !(first.is_ascii_alphabetic() || first == '_') {
561 return Err(McpError::new(
562 ErrorCode::InvalidArgument,
563 format!("Alias '{alias}' must start with a letter or underscore"),
564 ));
565 }
566 for c in chars {
567 if !(c.is_ascii_alphanumeric() || c == '_') {
568 return Err(McpError::new(
569 ErrorCode::InvalidArgument,
570 format!(
571 "Alias '{alias}' contains invalid character '{c}'. \
572 Allowed: [A-Za-z_][A-Za-z0-9_]{{0,62}}"
573 ),
574 ));
575 }
576 }
577 Ok(())
578}
579
580/// Validate a `LocalFile` path for the create-if-missing code path.
581///
582/// Looser than [`validate_local_path`] in one respect only: the target
583/// file itself need not exist yet. Everything else — absolute path,
584/// parent must exist, no `..` components after canonicalization — is
585/// enforced identically. Delegates to [`validate_local_path`] when the
586/// file is already present so the two paths produce the same canonical
587/// output.
588///
589/// # Errors
590///
591/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative, has
592/// no parent directory, has no file-name component, or if the
593/// canonicalized parent contains `..` components.
594/// - Returns [`ErrorCode::FileNotFound`] if the parent directory does
595/// not exist (canonicalization fails).
596/// - Delegates to [`validate_local_path`] when the file already exists,
597/// producing the same errors as that function.
598pub fn validate_local_path_for_create(path: &str) -> Result<PathBuf, McpError> {
599 let pb = PathBuf::from(path);
600 if !pb.is_absolute() {
601 return Err(McpError::new(
602 ErrorCode::InvalidArgument,
603 format!(
604 "Attach path '{path}' must be absolute. \
605 Pass a full path to a .hyper file."
606 ),
607 ));
608 }
609 if pb.exists() {
610 return validate_local_path(path);
611 }
612 let parent = pb.parent().ok_or_else(|| {
613 McpError::new(
614 ErrorCode::InvalidArgument,
615 format!("Attach path '{path}' has no parent directory"),
616 )
617 })?;
618 let file_name = pb.file_name().ok_or_else(|| {
619 McpError::new(
620 ErrorCode::InvalidArgument,
621 format!("Attach path '{path}' has no file-name component"),
622 )
623 })?;
624 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
625 McpError::new(
626 ErrorCode::FileNotFound,
627 format!(
628 "Parent directory of attach path '{path}' does not exist: {e}. \
629 Create the directory first or use on_missing='error'."
630 ),
631 )
632 })?;
633 if canonical_parent
634 .components()
635 .any(|c| matches!(c, std::path::Component::ParentDir))
636 {
637 return Err(McpError::new(
638 ErrorCode::InvalidArgument,
639 format!("Attach path '{path}' resolves to a location containing '..' components"),
640 ));
641 }
642 Ok(canonical_parent.join(file_name))
643}
644
645/// Validate a user-supplied file path that must already exist.
646///
647/// Must be absolute, must exist, must canonicalize cleanly with no `..`
648/// components in the result. Returns the canonical path on success.
649///
650/// `kind` is a short label used in error messages (e.g. `"data file"`,
651/// `"export"`, `"chart output"`). For attach paths use [`validate_local_path`]
652/// which uses `"attach path"` as the label.
653///
654/// # Errors
655///
656/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative or if the
657/// canonicalized path contains `..` components.
658/// - Returns [`ErrorCode::FileNotFound`] if `std::fs::canonicalize` fails.
659pub fn validate_input_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
660 let pb = PathBuf::from(path);
661 if !pb.is_absolute() {
662 return Err(McpError::new(
663 ErrorCode::InvalidArgument,
664 format!("{kind} path '{path}' must be absolute"),
665 ));
666 }
667 let canonical = std::fs::canonicalize(&pb).map_err(|e| {
668 McpError::new(
669 ErrorCode::FileNotFound,
670 format!("Cannot resolve {kind} path '{path}': {e}"),
671 )
672 })?;
673 if canonical
674 .components()
675 .any(|c| matches!(c, std::path::Component::ParentDir))
676 {
677 return Err(McpError::new(
678 ErrorCode::InvalidArgument,
679 format!("{kind} path '{path}' resolves to a location containing '..' components"),
680 ));
681 }
682 Ok(canonical)
683}
684
685/// Validate a user-supplied output path that may not yet exist.
686///
687/// Must be absolute. If the file exists, behaves like [`validate_input_path`].
688/// Otherwise the parent directory must exist and canonicalize cleanly.
689///
690/// # Errors
691///
692/// Same shape as [`validate_input_path`]; additionally returns
693/// [`ErrorCode::InvalidArgument`] if the path has no parent or no file-name.
694pub fn validate_output_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
695 let pb = PathBuf::from(path);
696 if !pb.is_absolute() {
697 return Err(McpError::new(
698 ErrorCode::InvalidArgument,
699 format!("{kind} path '{path}' must be absolute"),
700 ));
701 }
702 if pb.exists() {
703 return validate_input_path(path, kind);
704 }
705 let parent = pb.parent().ok_or_else(|| {
706 McpError::new(
707 ErrorCode::InvalidArgument,
708 format!("{kind} path '{path}' has no parent directory"),
709 )
710 })?;
711 let file_name = pb.file_name().ok_or_else(|| {
712 McpError::new(
713 ErrorCode::InvalidArgument,
714 format!("{kind} path '{path}' has no file-name component"),
715 )
716 })?;
717 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
718 McpError::new(
719 ErrorCode::FileNotFound,
720 format!("Parent directory of {kind} path '{path}' does not exist: {e}"),
721 )
722 })?;
723 if canonical_parent
724 .components()
725 .any(|c| matches!(c, std::path::Component::ParentDir))
726 {
727 return Err(McpError::new(
728 ErrorCode::InvalidArgument,
729 format!("{kind} path '{path}' resolves to a location containing '..' components"),
730 ));
731 }
732 Ok(canonical_parent.join(file_name))
733}
734
735/// Validate a `LocalFile` path. Must be absolute, must exist, must
736/// canonicalize cleanly with no `..` components in the result. Returns
737/// the canonical path on success.
738///
739/// # Errors
740///
741/// - Returns [`ErrorCode::InvalidArgument`] if `path` is relative or if
742/// the canonicalized path contains `..` components (symlink escape).
743/// - Returns [`ErrorCode::FileNotFound`] if `std::fs::canonicalize`
744/// fails — typically because the file does not exist or a parent
745/// directory is not traversable.
746pub fn validate_local_path(path: &str) -> Result<PathBuf, McpError> {
747 let pb = PathBuf::from(path);
748 if !pb.is_absolute() {
749 return Err(McpError::new(
750 ErrorCode::InvalidArgument,
751 format!(
752 "Attach path '{path}' must be absolute. \
753 Pass a full path to a local .hyper file."
754 ),
755 ));
756 }
757 let canonical = std::fs::canonicalize(&pb).map_err(|e| {
758 McpError::new(
759 ErrorCode::FileNotFound,
760 format!("Cannot resolve attach path '{path}': {e}"),
761 )
762 })?;
763 if canonical
764 .components()
765 .any(|c| matches!(c, std::path::Component::ParentDir))
766 {
767 return Err(McpError::new(
768 ErrorCode::InvalidArgument,
769 format!("Attach path '{path}' resolves to a location containing '..' components"),
770 ));
771 }
772 Ok(canonical)
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn alias_accepts_valid_identifiers() {
781 for a in ["src", "_scratch", "data_2024", "A", "alpha_beta_1"] {
782 validate_alias(a).unwrap_or_else(|e| panic!("expected {a:?} to be accepted: {e}"));
783 }
784 }
785
786 #[test]
787 fn alias_rejects_reserved_local() {
788 assert!(matches!(
789 validate_alias("local").unwrap_err().code,
790 ErrorCode::InvalidArgument
791 ));
792 assert!(matches!(
793 validate_alias("LOCAL").unwrap_err().code,
794 ErrorCode::InvalidArgument
795 ));
796 }
797
798 #[test]
799 fn alias_rejects_bad_shapes() {
800 for a in [
801 "",
802 "1abc",
803 "has space",
804 "a-b",
805 "a.b",
806 "a\"b",
807 &"a".repeat(64),
808 ] {
809 let err = validate_alias(a).expect_err(&format!("expected {a:?} to be rejected"));
810 assert_eq!(err.code, ErrorCode::InvalidArgument, "alias={a:?}");
811 }
812 }
813
814 #[test]
815 fn path_rejects_relative() {
816 let err = validate_local_path("relative/path.hyper").unwrap_err();
817 assert_eq!(err.code, ErrorCode::InvalidArgument);
818 }
819
820 #[test]
821 fn path_rejects_missing() {
822 // Build an absolute path that is guaranteed not to exist on any OS.
823 let missing = std::env::temp_dir().join("hyper_mcp_definitely_missing_99999.hyper");
824 let err = validate_local_path(missing.to_str().unwrap()).unwrap_err();
825 assert_eq!(err.code, ErrorCode::FileNotFound);
826 }
827
828 #[test]
829 fn path_canonicalizes_existing_file() {
830 let dir = tempfile::tempdir().unwrap();
831 let file = dir.path().join("sample.hyper");
832 std::fs::write(&file, b"").unwrap();
833 // Construct a path with a `.` component that canonicalization
834 // should flatten.
835 let noisy = dir.path().join(".").join("sample.hyper");
836 let resolved = validate_local_path(noisy.to_str().unwrap()).unwrap();
837 assert_eq!(resolved, std::fs::canonicalize(&file).unwrap());
838 }
839
840 #[test]
841 fn attached_db_to_json_round_trip() {
842 let entry = AttachedDb {
843 alias: "src".into(),
844 source: AttachSource::LocalFile {
845 path: PathBuf::from("/tmp/foo.hyper"),
846 },
847 writable: false,
848 attached_at: SystemTime::UNIX_EPOCH,
849 };
850 let j = entry.to_json();
851 assert_eq!(j["alias"], "src");
852 assert_eq!(j["writable"], false);
853 assert_eq!(j["kind"], "local_file");
854 assert_eq!(j["source"]["kind"], "local_file");
855 assert_eq!(j["source"]["path"], "/tmp/foo.hyper");
856 }
857
858 // -----------------------------------------------------------------
859 // validate_input_path / validate_output_path
860 // -----------------------------------------------------------------
861
862 #[test]
863 fn validate_input_path_rejects_relative() {
864 let err = validate_input_path("relative/path.csv", "data file").unwrap_err();
865 assert_eq!(err.code, ErrorCode::InvalidArgument);
866 assert!(err.message.contains("data file"));
867 }
868
869 #[test]
870 fn validate_input_path_rejects_missing() {
871 // Build a platform-portable absolute path to a nonexistent file.
872 // Hardcoded "/definitely/..." paths are not absolute on Windows
873 // (no drive letter), so they fail the wrong gate.
874 let missing = std::env::temp_dir().join("hyper_mcp_validate_input_missing_99999.csv");
875 let err = validate_input_path(missing.to_str().unwrap(), "data file").unwrap_err();
876 assert_eq!(err.code, ErrorCode::FileNotFound);
877 }
878
879 #[test]
880 fn validate_input_path_accepts_existing_file() {
881 let f = tempfile::NamedTempFile::new().unwrap();
882 let canonical = validate_input_path(f.path().to_str().unwrap(), "data file").unwrap();
883 assert!(canonical.is_absolute());
884 }
885
886 #[test]
887 fn validate_input_path_kind_appears_in_error() {
888 let err = validate_input_path("relative.csv", "iceberg table").unwrap_err();
889 assert!(
890 err.message.contains("iceberg table"),
891 "got: {}",
892 err.message
893 );
894 }
895
896 #[test]
897 fn validate_output_path_rejects_relative() {
898 let err = validate_output_path("relative/out.csv", "export").unwrap_err();
899 assert_eq!(err.code, ErrorCode::InvalidArgument);
900 }
901
902 #[test]
903 fn validate_output_path_accepts_nonexistent_with_existing_parent() {
904 let dir = tempfile::tempdir().unwrap();
905 let target = dir.path().join("does-not-exist-yet.csv");
906 let canonical =
907 validate_output_path(target.to_str().unwrap(), "export").expect("should accept");
908 assert!(canonical.is_absolute());
909 // The returned path should point at our target (canonical parent + name).
910 assert_eq!(canonical.file_name(), target.file_name());
911 }
912
913 #[test]
914 fn validate_output_path_rejects_missing_parent() {
915 // Build a platform-portable absolute path with a missing parent.
916 // Hardcoded "/definitely/..." paths are not absolute on Windows.
917 let missing_parent = std::env::temp_dir()
918 .join("hyper_mcp_validate_output_missing_parent_99999")
919 .join("out.csv");
920 let err = validate_output_path(missing_parent.to_str().unwrap(), "export").unwrap_err();
921 assert_eq!(err.code, ErrorCode::FileNotFound);
922 }
923
924 #[test]
925 fn validate_output_path_accepts_existing_file() {
926 let f = tempfile::NamedTempFile::new().unwrap();
927 let canonical = validate_output_path(f.path().to_str().unwrap(), "export").unwrap();
928 assert!(canonical.is_absolute());
929 }
930}