Skip to main content

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}