mcp_execution_core/confinement.rs
1//! Shared path-confinement algorithm for caller-supplied output paths.
2//!
3//! `mcp-execution-skill`'s `save_skill` and `mcp-execution-server`'s `introspect_server` both
4//! accept an optional caller-supplied relative path (`output_path`, `output_dir`) that must be
5//! confined to a per-server subdirectory of a trusted base directory: without confinement, an
6//! absolute path, a `..`-relative path, or a path that walks through a symlink planted inside
7//! the base directory lets a caller redirect a write anywhere the process can reach (issues
8//! #184, #216, #217). Both crates walked an identical component-by-component resolve-and-confine
9//! loop with their own error types; [`resolve_confined_path`] is that walk, extracted once so
10//! the two copies cannot silently drift apart.
11//!
12//! The absolute-path, `..`-component, and file-name pre-checks stay in each crate's own
13//! `relative_subpath`/`relative_target` helper, since callers disagree on what an *absent* path
14//! means (no subdirectory override, vs. the default `SKILL.md`) and on whether an empty result
15//! is legitimate. Only the shared filesystem walk — segment resolution, the lenient intermediate
16//! walk, and the terminal-component check — lives here.
17
18use std::ffi::OsStr;
19use std::io::{ErrorKind, Write};
20use std::path::{Component, Path, PathBuf};
21use thiserror::Error;
22
23use crate::path::{sanitize_path_for_error, validate_path_segment};
24use crate::untrusted::sanitize_untrusted_inline;
25
26/// The terminal path component a [`resolve_confined_path`] walk resolves and confinement-checks
27/// but deliberately does not create.
28///
29/// # Examples
30///
31/// ```
32/// use mcp_execution_core::ConfinementTarget;
33/// use std::ffi::OsStr;
34///
35/// let target = ConfinementTarget::File(OsStr::new("SKILL.md"));
36/// assert!(matches!(target, ConfinementTarget::File(_)));
37/// ```
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConfinementTarget<'a> {
40 /// The caller publishes the directory itself (e.g. via an atomic staged rename), so the
41 /// resolved directory is confinement-checked and canonicalized but not created.
42 Directory(&'a OsStr),
43 /// The caller writes the file itself, so the resolved path is confinement-checked but
44 /// neither created nor canonicalized. [`write_confined_file`] closes the symlink-planting
45 /// race at this exact terminal path between that check and the write itself (issue #496); a
46 /// plain [`tokio::fs::write`] does not. It does not defend against a symlink swapped in for a
47 /// parent directory after the check, nor a hardlink at the target — see its own doc comment
48 /// for the residual.
49 File(&'a OsStr),
50}
51
52/// Errors from walking and confining a path to a base directory.
53///
54/// Every variant is publicly constructible only by [`resolve_confined_path`] itself; callers map
55/// this enum into their own crate-specific error type with a total `From` implementation rather
56/// than matching on it directly, since two callers give the same failure different names (e.g.
57/// [`ConfinementError::WrongTargetKind`] means "not a directory" for one caller and "not a file"
58/// for the other).
59///
60/// # Examples
61///
62/// ```
63/// use mcp_execution_core::{ConfinementError, resolve_confined_path};
64/// use std::path::Path;
65///
66/// // Segment validation runs before any filesystem access, so this fails synchronously.
67/// let err = tokio::runtime::Builder::new_current_thread()
68/// .build()
69/// .unwrap()
70/// .block_on(resolve_confined_path(Path::new("/base"), "..", Path::new(""), None))
71/// .unwrap_err();
72/// assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
73/// ```
74#[derive(Debug, Error)]
75pub enum ConfinementError {
76 /// The path segment pushed onto the base directory (e.g. a `server_id`) is empty or is not
77 /// a single plain path component.
78 #[error("segment must be a single non-empty path segment: {segment:?}")]
79 InvalidSegment {
80 /// Sanitized display form of the rejected segment (see
81 /// [`sanitize_untrusted_inline`](crate::untrusted::sanitize_untrusted_inline)): control
82 /// characters, bidi-reordering characters, and other invisible/structural characters are
83 /// neutralized, and `&`/`<`/`>` are entity-escaped, since this value is
84 /// attacker-controlled and reaches LLM-facing error text. The `{segment:?}` (`Debug`)
85 /// formatting above is a required second layer of defense on top of that sanitization,
86 /// not incidental — see [`ServerIdError`](crate::ServerIdError)'s doc comment for why it
87 /// must not be "simplified" to `{segment}` (`Display`).
88 ///
89 /// This field is `pub` only because the enum itself is; [`resolve_confined_path`] is the
90 /// sole constructor of this variant, and it always passes an already-sanitized string
91 /// here. A caller building this variant directly (there are none in this workspace) must
92 /// sanitize the value the same way, or a raw value reaches every downstream consumer that
93 /// embeds this field (see `mcp-execution-server`'s `OutputDirError::InvalidServerId` and
94 /// `mcp-execution-skill`'s `OutputPathError::InvalidServerId`, both of which move this
95 /// field verbatim into their own `server_id` without re-sanitizing).
96 segment: String,
97 },
98
99 /// The segment's own directory already exists as a symlink, which is rejected outright
100 /// regardless of where it points - including at a sibling directory that still resolves
101 /// inside the base, which would otherwise pass a resolve-and-confine check (issue #217).
102 #[error("segment directory must not be a symlink: {path}")]
103 SegmentIsSymlink {
104 /// Sanitized display form of the offending path.
105 path: String,
106 },
107
108 /// The resolved path escapes the segment directory, typically because a path component
109 /// resolved through (or is itself) a symlink that points outside it.
110 #[error("resolved path escapes the confined directory: {path}")]
111 Escape {
112 /// Sanitized display form of the path that escaped confinement.
113 path: String,
114 },
115
116 /// A path component that must be a directory already exists as something else (e.g. a
117 /// regular file).
118 #[error("path component is not a directory: {path}")]
119 NotADirectory {
120 /// Sanitized display form of the offending component.
121 path: String,
122 },
123
124 /// The terminal component already exists as the kind of entry [`ConfinementTarget`] says it
125 /// isn't (a file where [`ConfinementTarget::Directory`] was expected, or a directory where
126 /// [`ConfinementTarget::File`] was expected).
127 #[error("path exists as the wrong kind of entry: {path}")]
128 WrongTargetKind {
129 /// Sanitized display form of the offending path.
130 path: String,
131 },
132
133 /// Creating a directory needed along the path failed.
134 #[error("failed to create directory {path}: {source}")]
135 CreateDir {
136 /// Sanitized display form of the directory that could not be created.
137 path: String,
138 /// Underlying I/O error.
139 #[source]
140 source: std::io::Error,
141 },
142
143 /// I/O error resolving the base directory or a path component.
144 #[error("failed to resolve confined path: {0}")]
145 Io(#[from] std::io::Error),
146}
147
148/// Resolves `segment`, then `relative_dirs`, then `target` (if any), confining every step to
149/// `base_dir/segment`.
150///
151/// `segment` is validated as a single plain path component (see [`validate_path_segment`]) and
152/// pushed onto `base_dir` (canonicalized once, since `base_dir` itself is trusted, first-party
153/// configuration rather than caller input). It is rejected outright if it already exists as a
154/// symlink, regardless of where it points, since a resolve-and-confine check alone would accept
155/// a symlink from a sibling directory that still resolves under `base_dir` (issue #217).
156///
157/// `relative_dirs` must already be `..`-free and non-absolute — callers reject those shapes in
158/// their own pre-check before calling this function, since what an absent or empty path means
159/// differs by caller. Each of its components is confined to the resolved segment directory (not
160/// merely to `base_dir` as a whole) and created if missing; an existing symlink is followed only
161/// if it still resolves inside the segment directory.
162///
163/// `target`, when supplied, names the walk's terminal component. It is confinement-checked but
164/// deliberately never created: a [`ConfinementTarget::Directory`] is resolved and canonicalized
165/// (the caller typically publishes it itself via an atomic staged rename), while a
166/// [`ConfinementTarget::File`] is checked but left exactly as constructed, uncanonicalized, since
167/// the caller is about to create it. `target: None` returns the walked `relative_dirs` chain
168/// itself. Either way the terminal component is rejected outright if it already exists as a
169/// symlink, dangling or not: a dangling symlink can't be resolved by `canonicalize`, but would
170/// still be followed by a subsequent write, so it is checked with `symlink_metadata` instead.
171///
172/// Each component is created and confinement-checked one at a time (rather than via a single
173/// recursive create-and-canonicalize), so a symlink already present under `base_dir` when this
174/// call starts — whether at `segment` or at any deeper component — is resolved and rejected
175/// *before* this function creates anything under it or descends into it. This is a check against
176/// pre-existing state, not a concurrency guarantee: it does not defend against a symlink planted
177/// by a racing process between this function's checks and the caller's subsequent write (see
178/// [`write_confined_file`], which closes that gap for the write itself at the terminal path —
179/// though not against a parent directory swapped for a symlink after this function returns, nor
180/// a hardlink at the target; see its own doc comment for the residual).
181///
182/// Directory creation along the walk *is* safe against concurrent callers resolving the same
183/// not-yet-existing path: `ErrorKind::AlreadyExists` from creating `segment`'s own directory or
184/// any `relative_dirs` component is tolerated, and the entry left behind by the caller that won
185/// the race is then validated exactly as if it had already existed when this call started -
186/// rejected under the same rules (symlink, wrong kind) rather than trusted just because someone
187/// else created it (issue #491). This is the only race this function defends against; the
188/// terminal `target` component is still never created, per the paragraph above.
189///
190/// # Errors
191///
192/// Returns [`ConfinementError`] if `segment` is empty or not a single plain path segment, if
193/// `segment`'s own directory already exists as a symlink, if the resolved path escapes
194/// `base_dir/segment` at any step, if a required directory could not be created, if a path
195/// component that must be a directory already exists as something else, or if `target`'s
196/// terminal component already exists as the wrong kind of entry.
197///
198/// # Examples
199///
200/// ```no_run
201/// use mcp_execution_core::{ConfinementTarget, resolve_confined_path};
202/// use std::ffi::OsStr;
203/// use std::path::Path;
204///
205/// # async fn example() -> Result<(), mcp_execution_core::ConfinementError> {
206/// let path = resolve_confined_path(
207/// Path::new("/home/user/.claude/skills"),
208/// "github",
209/// Path::new(""),
210/// Some(ConfinementTarget::File(OsStr::new("SKILL.md"))),
211/// )
212/// .await?;
213/// println!("Resolved to {}", path.display());
214/// # Ok(())
215/// # }
216/// ```
217pub async fn resolve_confined_path(
218 base_dir: &Path,
219 segment: &str,
220 relative_dirs: &Path,
221 target: Option<ConfinementTarget<'_>>,
222) -> Result<PathBuf, ConfinementError> {
223 let component =
224 validate_path_segment(segment).ok_or_else(|| ConfinementError::InvalidSegment {
225 segment: sanitize_untrusted_inline(segment),
226 })?;
227
228 tokio::fs::create_dir_all(base_dir)
229 .await
230 .map_err(|source| ConfinementError::CreateDir {
231 path: sanitize_path_for_error(base_dir),
232 source,
233 })?;
234 let canonical_root = tokio::fs::canonicalize(base_dir).await?;
235
236 let segment_dir = resolve_segment_dir(&canonical_root, component).await?;
237
238 let mut current = segment_dir.clone();
239 for dir_component in relative_dirs.components() {
240 current.push(dir_component);
241 resolve_lenient_component(&mut current, &segment_dir).await?;
242 }
243
244 match target {
245 None => Ok(current),
246 Some(target) => resolve_terminal(current, &segment_dir, target).await,
247 }
248}
249
250/// Validates `segment_dir`, which is already known to exist (`meta` is its `symlink_metadata`),
251/// under [`resolve_segment_dir`]'s strict policy: rejected outright if it's a symlink (regardless
252/// of where it points), or if it exists as anything other than a directory.
253fn validate_existing_segment_dir(
254 segment_dir: &Path,
255 meta: &std::fs::Metadata,
256) -> Result<(), ConfinementError> {
257 if meta.file_type().is_symlink() {
258 return Err(ConfinementError::SegmentIsSymlink {
259 path: sanitize_path_for_error(segment_dir),
260 });
261 }
262 if !meta.is_dir() {
263 return Err(ConfinementError::NotADirectory {
264 path: sanitize_path_for_error(segment_dir),
265 });
266 }
267 Ok(())
268}
269
270/// Resolves and confines `segment`'s own directory under `canonical_root`, rejecting it outright
271/// if it already exists as a symlink rather than resolving and re-checking it (see
272/// [`resolve_confined_path`]'s doc comment for why). Creates the directory if it doesn't exist
273/// yet; if a concurrent caller creates it first, `ErrorKind::AlreadyExists` is tolerated and the
274/// winner's directory is validated exactly as if it had already existed (issue #491).
275async fn resolve_segment_dir(
276 canonical_root: &Path,
277 component: Component<'_>,
278) -> Result<PathBuf, ConfinementError> {
279 let mut segment_dir = canonical_root.to_path_buf();
280 segment_dir.push(component);
281 if !segment_dir.starts_with(canonical_root) {
282 return Err(ConfinementError::Escape {
283 path: sanitize_path_for_error(&segment_dir),
284 });
285 }
286 if let Ok(meta) = tokio::fs::symlink_metadata(&segment_dir).await {
287 validate_existing_segment_dir(&segment_dir, &meta)?;
288 } else if let Err(source) = tokio::fs::create_dir(&segment_dir).await {
289 if source.kind() != ErrorKind::AlreadyExists {
290 return Err(ConfinementError::CreateDir {
291 path: sanitize_path_for_error(&segment_dir),
292 source,
293 });
294 }
295 let meta = tokio::fs::symlink_metadata(&segment_dir).await?;
296 validate_existing_segment_dir(&segment_dir, &meta)?;
297 }
298 Ok(segment_dir)
299}
300
301/// Confirms `current`, which is already known to exist, resolves (as a symlink or otherwise) to a
302/// directory still confined to `segment_dir` - [`resolve_lenient_component`]'s lenient policy,
303/// which resolves and re-checks an existing symlink rather than rejecting it outright.
304async fn validate_existing_lenient_component(
305 current: &mut PathBuf,
306 segment_dir: &Path,
307) -> Result<(), ConfinementError> {
308 let resolved = tokio::fs::canonicalize(¤t).await?;
309 if !resolved.starts_with(segment_dir) {
310 return Err(ConfinementError::Escape {
311 path: sanitize_path_for_error(current),
312 });
313 }
314 if !tokio::fs::metadata(&resolved).await?.is_dir() {
315 return Err(ConfinementError::NotADirectory {
316 path: sanitize_path_for_error(current),
317 });
318 }
319 *current = resolved;
320 Ok(())
321}
322
323/// Confines `current` to `segment_dir`, resolving (and confirming) an existing symlink rather
324/// than rejecting it outright, or creating the directory if it's missing. If a concurrent caller
325/// creates it first, `ErrorKind::AlreadyExists` is tolerated and the winner's directory is
326/// validated exactly as if it had already existed (issue #491).
327async fn resolve_lenient_component(
328 current: &mut PathBuf,
329 segment_dir: &Path,
330) -> Result<(), ConfinementError> {
331 if !current.starts_with(segment_dir) {
332 return Err(ConfinementError::Escape {
333 path: sanitize_path_for_error(current),
334 });
335 }
336 match tokio::fs::symlink_metadata(¤t).await {
337 Ok(_) => validate_existing_lenient_component(current, segment_dir).await,
338 Err(_) => match tokio::fs::create_dir(¤t).await {
339 Ok(()) => Ok(()),
340 Err(source) if source.kind() == ErrorKind::AlreadyExists => {
341 validate_existing_lenient_component(current, segment_dir).await
342 }
343 Err(source) => Err(ConfinementError::CreateDir {
344 path: sanitize_path_for_error(current),
345 source,
346 }),
347 },
348 }
349}
350
351/// Resolves and confinement-checks the walk's terminal component per `target`, without creating
352/// it. See [`ConfinementTarget`] and [`resolve_confined_path`]'s doc comment for the deliberate
353/// asymmetry between the two variants.
354async fn resolve_terminal(
355 mut current: PathBuf,
356 segment_dir: &Path,
357 target: ConfinementTarget<'_>,
358) -> Result<PathBuf, ConfinementError> {
359 match target {
360 ConfinementTarget::Directory(name) => {
361 current.push(name);
362 if !current.starts_with(segment_dir) {
363 return Err(ConfinementError::Escape {
364 path: sanitize_path_for_error(¤t),
365 });
366 }
367 if let Ok(meta) = tokio::fs::symlink_metadata(¤t).await {
368 if meta.file_type().is_symlink() {
369 return Err(ConfinementError::Escape {
370 path: sanitize_path_for_error(¤t),
371 });
372 }
373 let resolved = tokio::fs::canonicalize(¤t).await?;
374 if !resolved.starts_with(segment_dir) {
375 return Err(ConfinementError::Escape {
376 path: sanitize_path_for_error(¤t),
377 });
378 }
379 if !meta.is_dir() {
380 return Err(ConfinementError::WrongTargetKind {
381 path: sanitize_path_for_error(¤t),
382 });
383 }
384 current = resolved;
385 }
386 Ok(current)
387 }
388 ConfinementTarget::File(name) => {
389 let final_path = current.join(name);
390 if let Ok(meta) = tokio::fs::symlink_metadata(&final_path).await {
391 if meta.file_type().is_symlink() {
392 return Err(ConfinementError::Escape {
393 path: sanitize_path_for_error(&final_path),
394 });
395 }
396 if meta.is_dir() {
397 return Err(ConfinementError::WrongTargetKind {
398 path: sanitize_path_for_error(&final_path),
399 });
400 }
401 }
402 Ok(final_path)
403 }
404 }
405}
406
407/// Writes `content` to `path`, refusing to follow a symlink planted at `path`'s exact location
408/// after a caller's [`ConfinementTarget::File`] confinement check but before this call.
409///
410/// [`ConfinementTarget::File`]'s own doc comment calls out the gap this closes: resolving and
411/// confinement-checking a file target deliberately does not create it, so nothing stops a
412/// symlink from being planted at the resolved path between that check and the caller's write -
413/// and a plain [`tokio::fs::write`] would follow it, redirecting the write outside the confined
414/// directory (issue #496).
415///
416/// The entire check-then-write sequence runs inside a single [`tokio::task::spawn_blocking`]
417/// call, using `std::fs` rather than `tokio::fs`: this preserves the same atomicity a plain
418/// `tokio::fs::write` already had - once the blocking task is queued, dropping the returned
419/// future (e.g. because a client disconnected and `rmcp` drops the handler future) does not stop
420/// or partially undo a write that already started running on the blocking pool. An async-native
421/// version built from `tokio::fs::OpenOptions` plus `AsyncWriteExt::write_all` would not have
422/// this property: each `.await` point is a place a dropped future can land between `O_TRUNC`
423/// truncating the file and the content actually being written, leaving a 0-byte file behind
424/// instead of either the old or the new content — worse than not attempting the write at all.
425///
426/// On Unix, the open that creates or truncates `path` also carries `O_NOFOLLOW`, so the kernel
427/// rejects a symlinked terminal component (dangling or not) as part of that same syscall - there
428/// is no separate check-then-write step left for a racing process to land in between *for that
429/// exact path*. A pre-existing regular file is still opened and truncated normally: `O_NOFOLLOW`
430/// only rejects a symlink at the final path component, so this does not change overwrite
431/// semantics for a caller that already decided (via its own `exists()`/`overwrite` check) that
432/// clobbering an existing file is fine.
433///
434/// **Residual gaps, even on Unix**: `O_NOFOLLOW` only guards the *terminal* component. Something
435/// with write access further up the confined path (e.g. `{server_id}/` itself) could rename that
436/// directory aside and drop a symlink in its place after the caller's confinement check but
437/// before this call — the open then traverses the symlinked *parent* and still escapes, one
438/// directory level up from what a flag on the final `open` call can see. A hardlink planted at
439/// `path` (same filesystem) is also not a symlink, so `O_NOFOLLOW` does not reject it, and the
440/// write clobbers whatever the hardlink points at. Neither is defended against here; closing them
441/// would need a directory file descriptor captured during the confinement walk itself
442/// (`openat`/`openat2` with `RESOLVE_NO_SYMLINKS` on Linux) rather than a flag on the terminal
443/// open call alone.
444///
445/// Windows has no usable equivalent for this specific open, even though `custom_flags` is exposed
446/// there too (`std::fs::OpenOptions::custom_flags` via `std::os::windows::fs::OpenOptionsExt`):
447/// `FILE_FLAG_OPEN_REPARSE_POINT`, the flag that opens a reparse point (Windows's symlink
448/// mechanism) itself, is documented by `CreateFileW` as unusable together with `CREATE_ALWAYS` —
449/// which is exactly what a `create(true).truncate(true)` open maps to — and even where it can be
450/// used, it does not reject on open the way `O_NOFOLLOW` does; it hands back a handle to the link
451/// itself, which a plain write would still land on. So Windows relies solely on an
452/// immediately-preceding [`std::fs::symlink_metadata`] check, still inside the same blocking
453/// closure and so still with no yield point in between - this narrows the window against a
454/// symlink already present when the check runs, but remains genuinely open (not just narrowed) to
455/// a symlink a racing *process* plants between that check and the `open` call, since there is no
456/// single-syscall check-and-open on this platform. This crate's test suite has no Windows symlink
457/// coverage — creating a symlink there requires a privilege most CI runners don't grant.
458///
459/// # Errors
460///
461/// Returns [`ConfinementError::Io`] if the symlink check (Windows only), the open, or the write
462/// failed - including the platform's "too many levels of symbolic links" error on Unix when
463/// `path`'s terminal component is a symlink - or if the blocking task itself panicked.
464///
465/// # Examples
466///
467/// ```
468/// use mcp_execution_core::write_confined_file;
469/// use tempfile::TempDir;
470///
471/// let dir = TempDir::new().unwrap();
472/// let path = dir.path().join("SKILL.md");
473/// tokio::runtime::Builder::new_current_thread()
474/// .build()
475/// .unwrap()
476/// .block_on(write_confined_file(&path, b"---\nname: demo\n---\n"))
477/// .unwrap();
478/// assert_eq!(std::fs::read(&path).unwrap(), b"---\nname: demo\n---\n");
479/// ```
480pub async fn write_confined_file(path: &Path, content: &[u8]) -> Result<(), ConfinementError> {
481 let path = path.to_path_buf();
482 let content = content.to_vec();
483 // The first `?` propagates a `JoinError` (the blocking task panicked or was cancelled,
484 // wrapped via `Error::other`); the second propagates `write_confined_file_blocking`'s own
485 // `io::Result`. Both convert to `ConfinementError` via its `#[from] std::io::Error` variant.
486 tokio::task::spawn_blocking(move || write_confined_file_blocking(&path, &content))
487 .await
488 .map_err(std::io::Error::other)??;
489 Ok(())
490}
491
492/// The synchronous body of [`write_confined_file`], run inside a single `spawn_blocking` call so
493/// the check-then-write sequence is atomic with respect to the calling future being dropped.
494fn write_confined_file_blocking(path: &Path, content: &[u8]) -> std::io::Result<()> {
495 let mut file = open_confined_write(path)?;
496 file.write_all(content)?;
497 file.flush()
498}
499
500/// Opens `path` for writing, refusing to follow a pre-existing symlink planted at that exact
501/// location.
502///
503/// The same guard [`write_confined_file`] applies, factored out as its own blocking, synchronous
504/// primitive. [`write_confined_file`] stages content in memory and writes it to its *final* path
505/// in one call; a caller that instead needs to stage into a separate `.tmp` path and `rename` it into
506/// place (e.g. `mcp-execution-files`' `write_file_atomic`) cannot reuse that function directly,
507/// since the symlink race it closes is specific to the exact path passed in — here, the `.tmp`
508/// staging path rather than the final one (issue #504). Exposing this primitive lets both crates
509/// share one guard instead of each hand-rolling its own.
510///
511/// On Unix, the open that creates or truncates `path` carries `O_NOFOLLOW`, so the kernel rejects
512/// a symlinked terminal component (dangling or not) as part of that same syscall. Windows has no
513/// equivalent flag usable together with `create(true).truncate(true)` (see
514/// [`write_confined_file`]'s doc comment for why), so it relies solely on a
515/// [`std::fs::symlink_metadata`] pre-check with no yield point before the open — this narrows but
516/// does not close the window against a symlink planted by a racing process between the two.
517///
518/// A pre-existing regular file is still opened and truncated normally: this only rejects a
519/// symlink at the final path component, not an ordinary overwrite.
520///
521/// # Errors
522///
523/// Returns an error if the symlink check (Windows only) or the open failed — including the
524/// platform's "too many levels of symbolic links" error on Unix when `path`'s terminal component
525/// is a symlink.
526///
527/// # Examples
528///
529/// ```
530/// use mcp_execution_core::open_confined_write;
531/// use std::io::Write;
532/// use tempfile::TempDir;
533///
534/// let dir = TempDir::new().unwrap();
535/// let path = dir.path().join("staged.tmp");
536/// let mut file = open_confined_write(&path).unwrap();
537/// file.write_all(b"content").unwrap();
538/// ```
539pub fn open_confined_write(path: &Path) -> std::io::Result<std::fs::File> {
540 #[cfg(not(unix))]
541 if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
542 return Err(std::io::Error::new(
543 ErrorKind::AlreadyExists,
544 "refusing to write through a pre-existing symlink",
545 ));
546 }
547
548 let mut options = std::fs::OpenOptions::new();
549 options.write(true).create(true).truncate(true);
550 // No Windows equivalent: `FILE_FLAG_OPEN_REPARSE_POINT` is documented as unusable together
551 // with `CREATE_ALWAYS` (what `create(true).truncate(true)` maps to), and even where usable it
552 // doesn't reject on open the way `O_NOFOLLOW` does - see this function's doc comment. Windows
553 // relies solely on the `symlink_metadata` pre-check above.
554 #[cfg(unix)]
555 {
556 use std::os::unix::fs::OpenOptionsExt;
557 options.custom_flags(libc::O_NOFOLLOW);
558 }
559
560 options.open(path)
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use tempfile::TempDir;
567
568 #[tokio::test]
569 async fn target_none_returns_segment_dir_and_creates_nothing_beyond_it() {
570 let base = TempDir::new().unwrap();
571 let resolved = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
572 .await
573 .unwrap();
574 let canonical_base = base.path().canonicalize().unwrap();
575 assert_eq!(resolved, canonical_base.join("my-server"));
576 }
577
578 #[tokio::test]
579 async fn leading_cur_dir_resolves_like_its_normalized_form() {
580 let base = TempDir::new().unwrap();
581 let with_cur_dir = resolve_confined_path(
582 base.path(),
583 "my-server",
584 Path::new("./nested"),
585 Some(ConfinementTarget::File(OsStr::new("out.txt"))),
586 )
587 .await
588 .unwrap();
589 let normalized = resolve_confined_path(
590 base.path(),
591 "my-server",
592 Path::new("nested"),
593 Some(ConfinementTarget::File(OsStr::new("out.txt"))),
594 )
595 .await
596 .unwrap();
597 assert_eq!(with_cur_dir, normalized);
598 }
599
600 #[tokio::test]
601 async fn segment_empty_is_rejected() {
602 let base = TempDir::new().unwrap();
603 let err = resolve_confined_path(base.path(), "", Path::new(""), None)
604 .await
605 .unwrap_err();
606 assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
607 }
608
609 #[tokio::test]
610 async fn segment_with_parent_traversal_is_rejected() {
611 let base = TempDir::new().unwrap();
612 let err = resolve_confined_path(base.path(), "..", Path::new(""), None)
613 .await
614 .unwrap_err();
615 assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
616 }
617
618 /// Issue #452: a segment rejected for containing a path separator can still carry
619 /// printable-but-disallowed characters (`&`, `<`, `>`, emoji) elsewhere in it; none of those
620 /// may reach the error message unescaped.
621 #[tokio::test]
622 async fn segment_with_hostile_characters_is_escaped_in_error() {
623 let base = TempDir::new().unwrap();
624 for (candidate, escaped) in [
625 ("a/b&c", "a/b&c"),
626 ("a/b<c", "a/b<c"),
627 ("a/b>c", "a/b>c"),
628 ] {
629 let err = resolve_confined_path(base.path(), candidate, Path::new(""), None)
630 .await
631 .unwrap_err();
632 let message = err.to_string();
633 assert!(!message.contains(candidate), "{message:?}");
634 assert!(message.contains(escaped), "{message:?}");
635 }
636 }
637
638 /// S2: an emoji carries no structural or injection risk on its own, so it must appear in the
639 /// error message unchanged rather than being mangled — unlike `&`/`<`/`>` above.
640 #[tokio::test]
641 async fn segment_with_emoji_is_left_unchanged_in_error() {
642 let base = TempDir::new().unwrap();
643 let err = resolve_confined_path(base.path(), "a/b\u{1F600}c", Path::new(""), None)
644 .await
645 .unwrap_err();
646 assert!(err.to_string().contains("a/b\u{1F600}c"));
647 }
648
649 /// A legitimate non-ASCII segment must pass through `sanitize_untrusted_inline` unchanged;
650 /// only the separator that triggers `InvalidSegment` is the actual problem here.
651 #[tokio::test]
652 async fn segment_with_legitimate_non_ascii_is_left_unchanged_in_error() {
653 let base = TempDir::new().unwrap();
654 let err = resolve_confined_path(base.path(), "café/menu_日本語", Path::new(""), None)
655 .await
656 .unwrap_err();
657 assert!(err.to_string().contains("café/menu_日本語"));
658 }
659
660 /// `sanitize_untrusted_inline` deliberately leaves U+200D (ZERO WIDTH JOINER) untouched (see
661 /// its own doc comment), so `InvalidSegment`'s stored `segment` field still carries a raw
662 /// ZWJ. This is only safe because `{segment:?}` (`Debug`) formatting escapes it to
663 /// `\u{200d}` rather than emitting it verbatim — this test pins that second layer of
664 /// defense, mirroring `ServerIdError`'s equivalent regression test.
665 #[tokio::test]
666 async fn segment_with_zwj_is_debug_escaped_in_error() {
667 let base = TempDir::new().unwrap();
668 let err = resolve_confined_path(base.path(), "a/b\u{200D}c", Path::new(""), None)
669 .await
670 .unwrap_err();
671 let message = err.to_string();
672 assert!(
673 !message.contains('\u{200D}'),
674 "raw ZWJ leaked into: {message}"
675 );
676 assert!(message.contains("\\u{200d}"), "message was: {message}");
677 }
678
679 #[tokio::test]
680 async fn segment_with_path_separator_is_rejected() {
681 let base = TempDir::new().unwrap();
682 let err = resolve_confined_path(base.path(), "a/b", Path::new(""), None)
683 .await
684 .unwrap_err();
685 assert!(matches!(err, ConfinementError::InvalidSegment { .. }));
686 }
687
688 #[tokio::test]
689 #[cfg(unix)]
690 async fn segment_dir_symlink_to_outside_is_rejected() {
691 let base = TempDir::new().unwrap();
692 let outside = TempDir::new().unwrap();
693 std::os::unix::fs::symlink(outside.path(), base.path().join("my-server")).unwrap();
694
695 let err = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
696 .await
697 .unwrap_err();
698 assert!(matches!(err, ConfinementError::SegmentIsSymlink { .. }));
699 }
700
701 #[tokio::test]
702 #[cfg(unix)]
703 async fn segment_dir_symlink_to_sibling_is_rejected() {
704 let base = TempDir::new().unwrap();
705 tokio::fs::create_dir_all(base.path().join("server-a"))
706 .await
707 .unwrap();
708 std::os::unix::fs::symlink(base.path().join("server-a"), base.path().join("server-b"))
709 .unwrap();
710
711 let err = resolve_confined_path(base.path(), "server-b", Path::new(""), None)
712 .await
713 .unwrap_err();
714 assert!(matches!(err, ConfinementError::SegmentIsSymlink { .. }));
715 }
716
717 #[tokio::test]
718 #[cfg(unix)]
719 async fn segment_dir_that_is_a_regular_file_is_rejected() {
720 let base = TempDir::new().unwrap();
721 tokio::fs::write(base.path().join("my-server"), "oops")
722 .await
723 .unwrap();
724
725 let err = resolve_confined_path(base.path(), "my-server", Path::new(""), None)
726 .await
727 .unwrap_err();
728 assert!(matches!(err, ConfinementError::NotADirectory { .. }));
729 }
730
731 #[tokio::test]
732 #[cfg(unix)]
733 async fn lenient_walk_symlinked_intermediate_escape_is_rejected() {
734 let base = TempDir::new().unwrap();
735 let outside = TempDir::new().unwrap();
736 let server_dir = base.path().join("my-server");
737 tokio::fs::create_dir_all(&server_dir).await.unwrap();
738 std::os::unix::fs::symlink(outside.path(), server_dir.join("escape")).unwrap();
739
740 let err = resolve_confined_path(base.path(), "my-server", Path::new("escape/custom"), None)
741 .await
742 .unwrap_err();
743 assert!(matches!(err, ConfinementError::Escape { .. }));
744 }
745
746 #[tokio::test]
747 #[cfg(unix)]
748 async fn lenient_walk_regular_file_intermediate_is_rejected() {
749 let base = TempDir::new().unwrap();
750 let server_dir = base.path().join("my-server");
751 tokio::fs::create_dir_all(&server_dir).await.unwrap();
752 tokio::fs::write(server_dir.join("not-a-dir"), "oops")
753 .await
754 .unwrap();
755
756 let err = resolve_confined_path(
757 base.path(),
758 "my-server",
759 Path::new("not-a-dir/custom"),
760 None,
761 )
762 .await
763 .unwrap_err();
764 assert!(matches!(err, ConfinementError::NotADirectory { .. }));
765 }
766
767 #[tokio::test]
768 #[cfg(unix)]
769 async fn lenient_walk_symlink_loop_surfaces_as_io() {
770 let base = TempDir::new().unwrap();
771 let server_dir = base.path().join("my-server");
772 tokio::fs::create_dir_all(&server_dir).await.unwrap();
773 std::os::unix::fs::symlink("a", server_dir.join("a")).unwrap();
774
775 let err = resolve_confined_path(base.path(), "my-server", Path::new("a/custom"), None)
776 .await
777 .unwrap_err();
778 assert!(matches!(err, ConfinementError::Io(_)));
779 }
780
781 #[tokio::test]
782 #[cfg(unix)]
783 async fn dangling_symlink_at_terminal_is_rejected_under_both_targets() {
784 let base = TempDir::new().unwrap();
785 let outside = TempDir::new().unwrap();
786 let dangling_target = outside.path().join("does-not-exist");
787
788 let server_dir = base.path().join("my-server");
789 tokio::fs::create_dir_all(&server_dir).await.unwrap();
790 std::os::unix::fs::symlink(&dangling_target, server_dir.join("custom")).unwrap();
791
792 let dir_err = resolve_confined_path(
793 base.path(),
794 "my-server",
795 Path::new(""),
796 Some(ConfinementTarget::Directory(OsStr::new("custom"))),
797 )
798 .await
799 .unwrap_err();
800 assert!(matches!(dir_err, ConfinementError::Escape { .. }));
801
802 let file_err = resolve_confined_path(
803 base.path(),
804 "my-server",
805 Path::new(""),
806 Some(ConfinementTarget::File(OsStr::new("custom"))),
807 )
808 .await
809 .unwrap_err();
810 assert!(matches!(file_err, ConfinementError::Escape { .. }));
811 }
812
813 #[tokio::test]
814 #[cfg(unix)]
815 async fn symlink_to_existing_outside_file_at_terminal_is_rejected_under_both_targets() {
816 let base = TempDir::new().unwrap();
817 let outside = TempDir::new().unwrap();
818 let outside_file = outside.path().join("real");
819 tokio::fs::write(&outside_file, "outside").await.unwrap();
820
821 let server_dir = base.path().join("my-server");
822 tokio::fs::create_dir_all(&server_dir).await.unwrap();
823 std::os::unix::fs::symlink(&outside_file, server_dir.join("custom")).unwrap();
824
825 let dir_err = resolve_confined_path(
826 base.path(),
827 "my-server",
828 Path::new(""),
829 Some(ConfinementTarget::Directory(OsStr::new("custom"))),
830 )
831 .await
832 .unwrap_err();
833 assert!(matches!(dir_err, ConfinementError::Escape { .. }));
834
835 let file_err = resolve_confined_path(
836 base.path(),
837 "my-server",
838 Path::new(""),
839 Some(ConfinementTarget::File(OsStr::new("custom"))),
840 )
841 .await
842 .unwrap_err();
843 assert!(matches!(file_err, ConfinementError::Escape { .. }));
844 }
845
846 #[tokio::test]
847 #[cfg(unix)]
848 async fn terminal_exists_as_the_other_kind_under_both_targets() {
849 let base = TempDir::new().unwrap();
850 let server_dir = base.path().join("my-server");
851 tokio::fs::create_dir_all(&server_dir).await.unwrap();
852 tokio::fs::write(server_dir.join("custom"), "oops")
853 .await
854 .unwrap();
855
856 // A regular file where a directory was expected.
857 let dir_err = resolve_confined_path(
858 base.path(),
859 "my-server",
860 Path::new(""),
861 Some(ConfinementTarget::Directory(OsStr::new("custom"))),
862 )
863 .await
864 .unwrap_err();
865 assert!(matches!(dir_err, ConfinementError::WrongTargetKind { .. }));
866
867 // A directory where a file was expected.
868 let other_server_dir = base.path().join("other-server");
869 tokio::fs::create_dir_all(other_server_dir.join("custom"))
870 .await
871 .unwrap();
872 let file_err = resolve_confined_path(
873 base.path(),
874 "other-server",
875 Path::new(""),
876 Some(ConfinementTarget::File(OsStr::new("custom"))),
877 )
878 .await
879 .unwrap_err();
880 assert!(matches!(file_err, ConfinementError::WrongTargetKind { .. }));
881 }
882
883 /// Issue #491: two callers racing to resolve the same not-yet-existing segment directory
884 /// must both succeed - the loser's `create_dir` failing with `AlreadyExists` must be
885 /// tolerated and the winner's directory re-validated, not surfaced as a hard error.
886 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
887 async fn concurrent_first_time_segment_creation_both_succeed() {
888 let base = TempDir::new().unwrap();
889 let base_a = base.path().to_path_buf();
890 let base_b = base_a.clone();
891
892 let task_a = tokio::spawn(async move {
893 resolve_confined_path(&base_a, "my-server", Path::new(""), None).await
894 });
895 let task_b = tokio::spawn(async move {
896 resolve_confined_path(&base_b, "my-server", Path::new(""), None).await
897 });
898
899 let (a, b) = tokio::join!(task_a, task_b);
900 assert_eq!(a.unwrap().unwrap(), b.unwrap().unwrap());
901 }
902
903 /// Same race as above, one level deeper in the lenient `relative_dirs` walk.
904 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
905 async fn concurrent_first_time_relative_dir_creation_both_succeed() {
906 let base = TempDir::new().unwrap();
907 tokio::fs::create_dir_all(base.path().join("my-server"))
908 .await
909 .unwrap();
910 let base_a = base.path().to_path_buf();
911 let base_b = base_a.clone();
912
913 let task_a = tokio::spawn(async move {
914 resolve_confined_path(&base_a, "my-server", Path::new("nested"), None).await
915 });
916 let task_b = tokio::spawn(async move {
917 resolve_confined_path(&base_b, "my-server", Path::new("nested"), None).await
918 });
919
920 let (a, b) = tokio::join!(task_a, task_b);
921 assert_eq!(a.unwrap().unwrap(), b.unwrap().unwrap());
922 }
923
924 /// `write_confined_file`'s check-then-write sequence must run inside a single
925 /// `spawn_blocking` call so it is atomic with respect to the calling future being dropped
926 /// (e.g. `rmcp` dropping `save_skill`'s handler future on client disconnect) - an
927 /// async-native version built from `tokio::fs::OpenOptions` plus `AsyncWriteExt::write_all`
928 /// has multiple `.await` points, any of which a dropped future can land between `O_TRUNC`
929 /// truncating the file and the content being written, leaving a 0-byte file behind. Aborting
930 /// the spawned task immediately after starting it and re-reading the file must therefore
931 /// always observe either the untouched pre-existing content or the fully written new content
932 /// - never a partial or empty file.
933 ///
934 /// A single iteration only lands the abort inside the vulnerable window often enough to
935 /// discriminate the fix from the pre-fix async-await implementation about 60% of the time, so
936 /// this loops several times rather than relying on one attempt.
937 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
938 async fn write_confined_file_survives_future_drop_without_partial_write() {
939 const OLD: &[u8] = b"old-content-should-survive-or-be-fully-replaced";
940 const NEW: &[u8] = b"new-content-0123456789";
941
942 for _ in 0..8 {
943 let base = TempDir::new().unwrap();
944 let path = base.path().join("SKILL.md");
945 tokio::fs::write(&path, OLD).await.unwrap();
946
947 let spawn_path = path.clone();
948 let handle = tokio::spawn(async move { write_confined_file(&spawn_path, NEW).await });
949 std::thread::sleep(std::time::Duration::from_micros(1));
950 handle.abort();
951 let _ = handle.await;
952
953 // Poll briefly rather than a flat settle sleep: a blocking task that was already
954 // running when `abort` fired cannot be interrupted mid-flight, but a write this
955 // small finishes in well under a millisecond once scheduled, so most iterations
956 // don't need to wait at all.
957 let mut content = tokio::fs::read(&path).await.unwrap();
958 for _ in 0..50 {
959 if content.as_slice() == OLD || content.as_slice() == NEW {
960 break;
961 }
962 std::thread::sleep(std::time::Duration::from_millis(2));
963 content = tokio::fs::read(&path).await.unwrap();
964 }
965
966 assert!(
967 content.as_slice() == OLD || content.as_slice() == NEW,
968 "partial/corrupt content observed: {content:?}"
969 );
970 }
971 }
972
973 #[tokio::test]
974 async fn write_confined_file_creates_new_file() {
975 let base = TempDir::new().unwrap();
976 let path = base.path().join("SKILL.md");
977 write_confined_file(&path, b"content").await.unwrap();
978 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"content");
979 }
980
981 /// `write_confined_file` must preserve the overwrite semantics of a plain `tokio::fs::write`
982 /// for a pre-existing regular file - only a symlink at the terminal component is rejected.
983 #[tokio::test]
984 async fn write_confined_file_overwrites_existing_regular_file() {
985 let base = TempDir::new().unwrap();
986 let path = base.path().join("SKILL.md");
987 tokio::fs::write(&path, b"old").await.unwrap();
988 write_confined_file(&path, b"new").await.unwrap();
989 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new");
990 }
991
992 /// Issue #496: a symlink planted at the confined path after `resolve_confined_path`'s own
993 /// `ConfinementTarget::File` check (which deliberately leaves the terminal component
994 /// uncreated) must not be followed by the write that lands there.
995 #[tokio::test]
996 #[cfg(unix)]
997 async fn write_confined_file_rejects_a_symlink_planted_at_the_target() {
998 let base = TempDir::new().unwrap();
999 let outside = TempDir::new().unwrap();
1000 let outside_file = outside.path().join("real.md");
1001
1002 let confined_path = base.path().join("SKILL.md");
1003 std::os::unix::fs::symlink(&outside_file, &confined_path).unwrap();
1004
1005 let err = write_confined_file(&confined_path, b"attacker-controlled")
1006 .await
1007 .unwrap_err();
1008 // Not asserting the specific errno here: `O_NOFOLLOW` on a symlink is `ELOOP` on
1009 // Linux/macOS, but other Unix flavors (e.g. FreeBSD) surface `EMLINK` instead. The
1010 // load-bearing assertions are that it's an I/O failure at all, and that the write never
1011 // reached the symlink's target.
1012 assert!(matches!(err, ConfinementError::Io(_)));
1013 assert!(!outside_file.exists());
1014 }
1015
1016 #[cfg(windows)]
1017 #[tokio::test]
1018 async fn windows_root_relative_intermediate_cannot_escape_base() {
1019 let base = TempDir::new().unwrap();
1020 let err = resolve_confined_path(base.path(), "my-server", Path::new(r"\pwn\evil"), None)
1021 .await
1022 .unwrap_err();
1023 assert!(matches!(err, ConfinementError::Escape { .. }));
1024 }
1025}