Skip to main content

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(&current).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(&current).await {
337        Ok(_) => validate_existing_lenient_component(current, segment_dir).await,
338        Err(_) => match tokio::fs::create_dir(&current).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(&current),
365                });
366            }
367            if let Ok(meta) = tokio::fs::symlink_metadata(&current).await {
368                if meta.file_type().is_symlink() {
369                    return Err(ConfinementError::Escape {
370                        path: sanitize_path_for_error(&current),
371                    });
372                }
373                let resolved = tokio::fs::canonicalize(&current).await?;
374                if !resolved.starts_with(segment_dir) {
375                    return Err(ConfinementError::Escape {
376                        path: sanitize_path_for_error(&current),
377                    });
378                }
379                if !meta.is_dir() {
380                    return Err(ConfinementError::WrongTargetKind {
381                        path: sanitize_path_for_error(&current),
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&amp;c"),
626            ("a/b<c", "a/b&lt;c"),
627            ("a/b>c", "a/b&gt;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}