Skip to content

Commit da15db2

Browse files
committed
feat(spaces): base-dir storage foundation
Foundation for per-Space base directories (scope a workspace root to a Space by folder prefix). No behavior change yet — just the primitive + storage. - Domain: path_is_within / longest_matching_base (segment-boundary aware, Windows + POSIX, longest-prefix wins) + the SpaceBaseDir entity. - Storage: migration 019 space_base_dirs (UNIQUE path = one owner per folder, FK cascade) + SpaceBaseDirRepository (list/add/remove + find_space_for_root longest-prefix lookup). - Tests: 4 domain prefix tests + 4 repo tests (CRUD, uniqueness, longest prefix, cascade). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 7e5a319 commit da15db2

10 files changed

Lines changed: 400 additions & 2 deletions

File tree

crates/mcpmux-core/src/domain/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ pub use server_feature::*;
3838
pub use server_log::*;
3939
pub use space::*;
4040
pub use workspace_binding::{
41-
normalize_workspace_root, validate_workspace_root, WorkspaceBinding, WorkspaceRootValidation,
41+
longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root,
42+
WorkspaceBinding, WorkspaceRootValidation,
4243
};

crates/mcpmux-core/src/domain/space.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,24 @@ impl Default for Space {
7575
}
7676
}
7777

78+
/// A base directory claimed by a Space.
79+
///
80+
/// Any reported workspace root at or under `path` is scoped to `space_id`
81+
/// (see [`crate::domain::path_is_within`]). `path` is stored already-normalized
82+
/// via [`crate::domain::normalize_workspace_root`], and is globally unique —
83+
/// the same folder can't be a base dir of two Spaces.
84+
#[derive(Debug, Clone, Serialize, Deserialize)]
85+
pub struct SpaceBaseDir {
86+
/// Unique identifier for this base-dir row.
87+
pub id: String,
88+
/// The Space that owns this base directory.
89+
pub space_id: String,
90+
/// Normalized absolute path.
91+
pub path: String,
92+
/// Creation timestamp.
93+
pub created_at: DateTime<Utc>,
94+
}
95+
7896
#[cfg(test)]
7997
mod tests {
8098
use super::*;

crates/mcpmux-core/src/domain/workspace_binding.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,52 @@ pub enum WorkspaceRootValidation {
292292
Invalid { reason: String },
293293
}
294294

295+
/// True when `root` is the same folder as `base`, or nested inside it —
296+
/// matching on path-segment boundaries so `/work` contains `/work/proj` but
297+
/// NOT `/workspace`. Both arguments must already be normalized via
298+
/// [`normalize_workspace_root`] (same casing + separator conventions) for the
299+
/// comparison to be meaningful.
300+
///
301+
/// Used to scope a workspace root to a Space by its configured base
302+
/// directories: a reported root at or under a base dir belongs to that Space.
303+
pub fn path_is_within(root: &str, base: &str) -> bool {
304+
if base.is_empty() || root.is_empty() {
305+
return false;
306+
}
307+
if root == base {
308+
return true;
309+
}
310+
// Separator comes from the (normalized) base: Windows drive/UNC paths use
311+
// `\`, POSIX uses `/`.
312+
let sep = if base.contains('\\') { '\\' } else { '/' };
313+
if base.ends_with(sep) {
314+
// `base` is already a filesystem/drive root (`c:\`, `\\`, `/`) — its
315+
// trailing separator IS the boundary, so a plain prefix check is right.
316+
root.starts_with(base)
317+
} else {
318+
// Otherwise require the next char to be the separator so we only match
319+
// whole segments (`/work` ⊄ `/workspace`).
320+
let mut prefix = String::with_capacity(base.len() + 1);
321+
prefix.push_str(base);
322+
prefix.push(sep);
323+
root.starts_with(&prefix)
324+
}
325+
}
326+
327+
/// Of all `bases`, return the **longest** one that contains `root` (most
328+
/// specific wins when base dirs nest — `/work/client` beats `/work`), or
329+
/// `None` when none contain it. Inputs must be normalized. Equal-length bases
330+
/// can't both contain the same root, so length ties never occur in practice.
331+
pub fn longest_matching_base<'a>(
332+
root: &str,
333+
bases: impl IntoIterator<Item = &'a str>,
334+
) -> Option<&'a str> {
335+
bases
336+
.into_iter()
337+
.filter(|b| path_is_within(root, b))
338+
.max_by_key(|b| b.len())
339+
}
340+
295341
/// Validate a user-entered workspace root.
296342
///
297343
/// Applied on manual add/edit ONLY — roots reported by connected MCP
@@ -388,6 +434,55 @@ fn check_windows_reserved_chars(path: &str) -> Result<(), String> {
388434
mod tests {
389435
use super::*;
390436

437+
// ---- path_is_within / longest_matching_base --------------------------
438+
439+
#[test]
440+
fn within_posix_segment_boundaries() {
441+
assert!(path_is_within("/work", "/work")); // same folder
442+
assert!(path_is_within("/work/proj", "/work")); // nested
443+
assert!(path_is_within("/work/proj/deep", "/work")); // deeper
444+
assert!(!path_is_within("/workspace", "/work")); // NOT a segment boundary
445+
assert!(!path_is_within("/other", "/work"));
446+
assert!(!path_is_within("/work", "/work/proj")); // parent is not within child
447+
}
448+
449+
#[test]
450+
fn within_windows_is_case_and_sep_consistent() {
451+
// Inputs are already-normalized Windows form (lower-case, `\`).
452+
assert!(path_is_within("c:\\work\\proj", "c:\\work"));
453+
assert!(path_is_within("c:\\work", "c:\\work"));
454+
assert!(!path_is_within("c:\\workspace", "c:\\work"));
455+
// Drive root keeps its trailing separator and contains everything on it.
456+
assert!(path_is_within("c:\\proj", "c:\\"));
457+
}
458+
459+
#[test]
460+
fn within_filesystem_roots() {
461+
assert!(path_is_within("/work", "/")); // POSIX root contains all
462+
assert!(path_is_within("/", "/"));
463+
assert!(!path_is_within("/x", "")); // empty base never matches
464+
assert!(!path_is_within("", "/x")); // empty root never matches
465+
}
466+
467+
#[test]
468+
fn longest_base_wins_when_nested() {
469+
let bases = ["/work", "/work/client", "/other"];
470+
assert_eq!(
471+
longest_matching_base("/work/client/app", bases.iter().copied()),
472+
Some("/work/client"),
473+
);
474+
// A root under only the broad base falls to that one.
475+
assert_eq!(
476+
longest_matching_base("/work/solo", bases.iter().copied()),
477+
Some("/work"),
478+
);
479+
// No base contains it.
480+
assert_eq!(
481+
longest_matching_base("/elsewhere", bases.iter().copied()),
482+
None,
483+
);
484+
}
485+
391486
// ---- normalize -------------------------------------------------------
392487

393488
#[test]

crates/mcpmux-core/src/repository/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use uuid::Uuid;
88

99
use crate::domain::{
1010
Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode,
11-
OutboundOAuthRegistration, ServerFeature, Space, WorkspaceBinding,
11+
OutboundOAuthRegistration, ServerFeature, Space, SpaceBaseDir, WorkspaceBinding,
1212
};
1313

1414
/// Result type for repository operations
@@ -39,6 +39,32 @@ pub trait SpaceRepository: Send + Sync {
3939
async fn set_default(&self, id: &Uuid) -> RepoResult<()>;
4040
}
4141

42+
/// Per-Space base-directory repository.
43+
///
44+
/// Manages the folders a Space claims. A reported workspace root at or under a
45+
/// base dir is scoped to that Space (longest-prefix wins when base dirs nest).
46+
#[async_trait]
47+
pub trait SpaceBaseDirRepository: Send + Sync {
48+
/// Every base dir across all Spaces.
49+
async fn list_all(&self) -> RepoResult<Vec<SpaceBaseDir>>;
50+
51+
/// Base dirs for one Space.
52+
async fn list_by_space(&self, space_id: &Uuid) -> RepoResult<Vec<SpaceBaseDir>>;
53+
54+
/// Add a base dir to a Space. `path` MUST already be normalized via
55+
/// [`crate::domain::normalize_workspace_root`]. Returns an error if the
56+
/// path is already claimed by any Space (one owner per path).
57+
async fn add(&self, space_id: &Uuid, path: &str) -> RepoResult<SpaceBaseDir>;
58+
59+
/// Remove a base dir by its row id.
60+
async fn remove(&self, id: &str) -> RepoResult<()>;
61+
62+
/// The Space whose base dir is the longest prefix of `root` (already
63+
/// normalized), or `None` when no base dir contains it. Most-specific
64+
/// (longest) base dir wins when several nest.
65+
async fn find_space_for_root(&self, root: &str) -> RepoResult<Option<Uuid>>;
66+
}
67+
4268
/// InstalledServer repository trait
4369
#[async_trait]
4470
pub trait InstalledServerRepository: Send + Sync {

crates/mcpmux-storage/src/database.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ const MIGRATIONS: &[Migration] = &[
123123
name: "starter_is_default_fallback_copy",
124124
sql: include_str!("migrations/018_starter_is_default_fallback_copy.sql"),
125125
},
126+
Migration {
127+
version: 19,
128+
name: "space_base_dirs",
129+
sql: include_str!("migrations/019_space_base_dirs.sql"),
130+
},
126131
];
127132

128133
/// SQLite database wrapper.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Migration 019: per-Space base directories.
2+
--
3+
-- A Space can claim one or more base directories. Any workspace root reported
4+
-- by a connected client that sits at or under a base dir is "scoped" to that
5+
-- Space: an unmapped root falls back to that Space's Starter (not the global
6+
-- default), and the meta-tools / mapping popup restrict to that Space.
7+
--
8+
-- `path` is the NORMALIZED workspace root (see `normalize_workspace_root`:
9+
-- lower-cased drive letter on Windows, `\` separators, no trailing slash).
10+
-- UNIQUE(path) enforces one owner per exact path — the same folder can't be a
11+
-- base dir of two Spaces. Nesting across Spaces is allowed and resolved by
12+
-- longest-prefix at lookup time. ON DELETE CASCADE drops a Space's base dirs
13+
-- with it.
14+
15+
CREATE TABLE IF NOT EXISTS space_base_dirs (
16+
id TEXT PRIMARY KEY,
17+
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
18+
path TEXT NOT NULL UNIQUE,
19+
created_at TEXT NOT NULL
20+
);
21+
22+
CREATE INDEX IF NOT EXISTS idx_space_base_dirs_space ON space_base_dirs(space_id);

crates/mcpmux-storage/src/repositories/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod inbound_mcp_client_repository;
88
mod installed_server_repository;
99
mod outbound_oauth_client_repository;
1010
mod server_feature_repository;
11+
mod space_base_dir_repository;
1112
mod space_builtin_config_repository;
1213
mod space_repository;
1314
mod workspace_binding_repository;
@@ -25,6 +26,7 @@ pub use outbound_oauth_client_repository::SqliteOutboundOAuthRepository;
2526
pub use server_feature_repository::{
2627
FeatureType, ServerFeature, ServerFeatureRepository, SqliteServerFeatureRepository,
2728
};
29+
pub use space_base_dir_repository::SqliteSpaceBaseDirRepository;
2830
pub use space_builtin_config_repository::SqliteSpaceBuiltinConfigRepository;
2931
pub use space_repository::SqliteSpaceRepository;
3032
pub use workspace_binding_repository::SqliteWorkspaceBindingRepository;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! SQLite implementation of SpaceBaseDirRepository.
2+
3+
use std::sync::Arc;
4+
5+
use anyhow::Result;
6+
use async_trait::async_trait;
7+
use chrono::{DateTime, Utc};
8+
use mcpmux_core::{path_is_within, SpaceBaseDir, SpaceBaseDirRepository};
9+
use rusqlite::params;
10+
use tokio::sync::Mutex;
11+
use uuid::Uuid;
12+
13+
use crate::Database;
14+
15+
/// SQLite-backed implementation of [`SpaceBaseDirRepository`].
16+
pub struct SqliteSpaceBaseDirRepository {
17+
db: Arc<Mutex<Database>>,
18+
}
19+
20+
impl SqliteSpaceBaseDirRepository {
21+
pub fn new(db: Arc<Mutex<Database>>) -> Self {
22+
Self { db }
23+
}
24+
25+
fn parse_datetime(s: &str) -> DateTime<Utc> {
26+
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
27+
return dt.with_timezone(&Utc);
28+
}
29+
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
30+
return dt.and_utc();
31+
}
32+
Utc::now()
33+
}
34+
35+
/// Columns selected for every read. Order must match `map_row`.
36+
const COLUMNS: &'static str = "id, space_id, path, created_at";
37+
38+
fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SpaceBaseDir> {
39+
Ok(SpaceBaseDir {
40+
id: row.get(0)?,
41+
space_id: row.get(1)?,
42+
path: row.get(2)?,
43+
created_at: Self::parse_datetime(&row.get::<_, String>(3)?),
44+
})
45+
}
46+
}
47+
48+
#[async_trait]
49+
impl SpaceBaseDirRepository for SqliteSpaceBaseDirRepository {
50+
async fn list_all(&self) -> Result<Vec<SpaceBaseDir>> {
51+
let db = self.db.lock().await;
52+
let conn = db.connection();
53+
let mut stmt = conn.prepare(&format!(
54+
"SELECT {} FROM space_base_dirs ORDER BY path ASC",
55+
Self::COLUMNS
56+
))?;
57+
let rows = stmt
58+
.query_map([], Self::map_row)?
59+
.collect::<rusqlite::Result<Vec<_>>>()?;
60+
Ok(rows)
61+
}
62+
63+
async fn list_by_space(&self, space_id: &Uuid) -> Result<Vec<SpaceBaseDir>> {
64+
let db = self.db.lock().await;
65+
let conn = db.connection();
66+
let mut stmt = conn.prepare(&format!(
67+
"SELECT {} FROM space_base_dirs WHERE space_id = ? ORDER BY path ASC",
68+
Self::COLUMNS
69+
))?;
70+
let rows = stmt
71+
.query_map(params![space_id.to_string()], Self::map_row)?
72+
.collect::<rusqlite::Result<Vec<_>>>()?;
73+
Ok(rows)
74+
}
75+
76+
async fn add(&self, space_id: &Uuid, path: &str) -> Result<SpaceBaseDir> {
77+
if path.trim().is_empty() {
78+
anyhow::bail!("Base directory path is empty");
79+
}
80+
let row = SpaceBaseDir {
81+
id: Uuid::new_v4().to_string(),
82+
space_id: space_id.to_string(),
83+
path: path.to_string(),
84+
created_at: Utc::now(),
85+
};
86+
let db = self.db.lock().await;
87+
let conn = db.connection();
88+
conn.execute(
89+
"INSERT INTO space_base_dirs (id, space_id, path, created_at) VALUES (?1, ?2, ?3, ?4)",
90+
params![row.id, row.space_id, row.path, row.created_at.to_rfc3339()],
91+
)
92+
.map_err(|e| {
93+
// Turn the UNIQUE(path) collision into an actionable message — the
94+
// folder already belongs to some Space (possibly this one).
95+
if e.to_string().to_lowercase().contains("unique") {
96+
anyhow::anyhow!("That folder is already a base directory of a space: {path}")
97+
} else {
98+
anyhow::Error::from(e)
99+
}
100+
})?;
101+
Ok(row)
102+
}
103+
104+
async fn remove(&self, id: &str) -> Result<()> {
105+
let db = self.db.lock().await;
106+
let conn = db.connection();
107+
conn.execute("DELETE FROM space_base_dirs WHERE id = ?", params![id])?;
108+
Ok(())
109+
}
110+
111+
async fn find_space_for_root(&self, root: &str) -> Result<Option<Uuid>> {
112+
if root.trim().is_empty() {
113+
return Ok(None);
114+
}
115+
// Few base dirs in practice — load them and pick the longest prefix
116+
// match in Rust (reuses the byte-proven `path_is_within`). The db lock
117+
// is taken and released inside `list_all`, so there's no double-lock.
118+
let all = self.list_all().await?;
119+
let best = all
120+
.iter()
121+
.filter(|bd| path_is_within(root, &bd.path))
122+
.max_by_key(|bd| bd.path.len());
123+
Ok(best.and_then(|bd| bd.space_id.parse::<Uuid>().ok()))
124+
}
125+
}

tests/rust/tests/database/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ mod installed_server;
1515
mod migrations;
1616
mod outbound_oauth;
1717
mod repositories;
18+
mod space_base_dir;

0 commit comments

Comments
 (0)