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/// 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}