Skip to main content

mcpls_core/bridge/
resources.rs

1//! MCP resource URI codec and subscription tracking for LSP diagnostics.
2//!
3//! Resources in mcpls use the `lsp-diagnostics:///` scheme (RFC 3986 compliant,
4//! empty authority, percent-encoded path). Each resource corresponds to a single
5//! file whose diagnostics are cached from LSP `textDocument/publishDiagnostics`
6//! notifications.
7
8use std::collections::{HashMap, HashSet};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12use thiserror::Error;
13use tokio::sync::RwLock;
14use url::Url;
15
16use super::state::{encode_rfc3986_path_chars, file_url, uri_to_path};
17use super::{ClientPath, InvalidClientPath, WorkspaceRoots};
18use crate::util::{MAX_ECHOED_PATH_BYTES, escape_bounded};
19
20/// URI scheme used for diagnostic resources.
21const SCHEME: &str = "lsp-diagnostics";
22
23/// Full scheme + authority prefix (`scheme://`).
24///
25/// Three-slash form (`lsp-diagnostics:///`) is produced by appending an empty
26/// authority and the absolute path: `{PREFIX}{path}`.
27const PREFIX: &str = "lsp-diagnostics://";
28
29/// Maximum number of resource URIs a single client session may subscribe to.
30///
31/// Guards against memory exhaustion from a misbehaving or adversarial client.
32pub const MAX_SUBSCRIPTIONS: usize = 1_000;
33
34/// Maximum number of concurrent `subscriptions/listen` streams, across all
35/// transports.
36///
37/// Listen streams are bounded by their connection and take no
38/// `max_concurrent_sessions` slot, so they need a cap of their own. A
39/// constant, not a knob: it equals the default `HttpConfig` session cap and
40/// is independent of the configured one.
41pub(crate) const MAX_LISTEN_STREAMS: usize = 100;
42
43/// Errors produced by the resource URI codec.
44#[derive(Debug, Error)]
45pub enum ResourceUriError {
46    /// The path is relative or contains non-UTF-8 components.
47    #[error(
48        "path must be absolute and valid UTF-8: {}",
49        escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
50    )]
51    InvalidPath(String),
52
53    /// The URI has the wrong scheme or malformed structure.
54    #[error(
55        "expected '{SCHEME}:///' prefix in URI: {}",
56        escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
57    )]
58    InvalidScheme(String),
59
60    /// The URI is not parseable as a URL.
61    #[error("malformed resource URI")]
62    UrlParse(#[source] url::ParseError),
63
64    /// The URI does not name a filesystem path.
65    #[error(
66        "URI does not name a filesystem path: {}",
67        escape_bounded(.0, MAX_ECHOED_PATH_BYTES)
68    )]
69    NotAFilePath(String),
70
71    /// The decoded path is not an acceptable client path.
72    #[error("decoded URI path is not acceptable")]
73    ClientPath(#[source] InvalidClientPath),
74}
75
76/// Errors produced when adding a URI to a [`ResourceSubscriptions`] set.
77#[derive(Debug, Error, PartialEq, Eq)]
78pub(crate) enum SubscriptionError {
79    /// The session's subscription set has already reached [`MAX_SUBSCRIPTIONS`].
80    #[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
81    LimitReached,
82
83    /// [`MAX_LISTEN_STREAMS`] listen streams are already open.
84    #[error("listen stream limit of {MAX_LISTEN_STREAMS} reached")]
85    ListenLimitReached,
86}
87
88impl From<SubscriptionError> for crate::error::Error {
89    fn from(err: SubscriptionError) -> Self {
90        match err {
91            SubscriptionError::LimitReached => Self::SubscriptionLimitReached {
92                max: MAX_SUBSCRIPTIONS,
93            },
94            SubscriptionError::ListenLimitReached => Self::ListenStreamsExhausted {
95                max: MAX_LISTEN_STREAMS,
96            },
97        }
98    }
99}
100
101/// Encode an absolute filesystem path into a `lsp-diagnostics:///…` resource URI.
102///
103/// Percent-encoding is delegated to [`url::Url::from_file_path`], which
104/// handles spaces, unicode, `%`, `?`, `#`, and platform separators correctly,
105/// plus an additional pass for the RFC 3986 §2.2 "other reserved" characters
106/// (`[ ] ^ |`) that `url` otherwise leaves unescaped — the same encoding
107/// applied to `file://` URIs.
108///
109/// # Errors
110///
111/// Returns [`ResourceUriError::InvalidPath`] if the path is relative or
112/// cannot be expressed as a valid file URI.
113///
114/// # Examples
115///
116/// ```
117/// use std::path::Path;
118/// use mcpls_core::bridge::resources::make_uri;
119///
120/// let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
121/// assert!(uri.starts_with("lsp-diagnostics:///"));
122/// ```
123pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
124    let file_url =
125        file_url(path).ok_or_else(|| ResourceUriError::InvalidPath(path.display().to_string()))?;
126
127    // Replace the "file" scheme with our custom scheme while keeping the
128    // percent-encoded path and authority (empty) components.
129    let encoded = encode_rfc3986_path_chars(&file_url);
130    let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
131    let uri = format!("{SCHEME}{after_scheme}");
132    Ok(uri)
133}
134
135/// Decode a `lsp-diagnostics:///…` resource URI back to an absolute filesystem path.
136///
137/// # Errors
138///
139/// Returns an error if the URI does not start with the expected scheme,
140/// or if the percent-encoded path cannot be mapped to a filesystem path
141/// (including a decoded NUL byte).
142///
143/// # Examples
144///
145/// ```
146/// use std::path::Path;
147/// use mcpls_core::bridge::resources::{make_uri, parse_uri};
148///
149/// let path = Path::new("/home/user/main.rs");
150/// let uri = make_uri(path).unwrap();
151/// let recovered = parse_uri(&uri).unwrap();
152/// assert_eq!(recovered.as_path(), path);
153/// ```
154pub fn parse_uri(uri: &str) -> Result<ClientPath, ResourceUriError> {
155    if !uri.starts_with(PREFIX) {
156        return Err(ResourceUriError::InvalidScheme(uri.to_string()));
157    }
158
159    // Require empty authority: the character immediately after `://` must be `/`.
160    // This blocks `lsp-diagnostics://evil-host/path` → UNC path on Windows.
161    let after_prefix = &uri[PREFIX.len()..];
162    if !after_prefix.starts_with('/') {
163        return Err(ResourceUriError::InvalidScheme(format!(
164            "non-empty authority in URI: {uri}"
165        )));
166    }
167
168    let file_uri = format!("file://{after_prefix}");
169    let url = Url::parse(&file_uri).map_err(ResourceUriError::UrlParse)?;
170
171    let path = url
172        .to_file_path()
173        .map_err(|()| ResourceUriError::NotAFilePath(file_uri))?;
174    ClientPath::try_from(path).map_err(ResourceUriError::ClientPath)
175}
176
177/// A `lsp-diagnostics:///` resource URI in the form the diagnostics pump
178/// publishes under -- the only key subscription and delivery state uses.
179///
180/// The field is private, so a value is either the result of
181/// [`Self::resolve`] (a client URI parsed, workspace-checked and re-encoded
182/// from its canonical path) or of [`Self::for_published`] (derived from a
183/// path an LSP server published diagnostics for). Raw client strings
184/// therefore cannot reach the subscription set, the pending set or the
185/// delivery target without going through one of them.
186#[derive(Debug, Clone, PartialEq, Eq, Hash)]
187pub(crate) struct DiagnosticsResourceUri(String);
188
189/// A URI an LSP server published diagnostics for, paired with the canonical
190/// URI of the same file.
191///
192/// A server may publish through a symlinked spelling of a path (rust-analyzer
193/// does, for cargo-metadata paths); keying by that raw spelling would never
194/// match a subscription or read made through the canonical path. The only
195/// constructor is [`Self::from_canonical_path`], fed by the pump's resolver,
196/// so a published URI cannot reach the cache index or a subscription key
197/// without being canonicalized and workspace-checked.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub(crate) struct PublishedDiagnosticsUri {
200    source: lsp_types::Uri,
201    canonical: lsp_types::Uri,
202    /// Whether the server spelled the path canonically, decided on the paths
203    /// rather than the URI text so percent-encoding and drive-letter case
204    /// cannot make a canonical spelling look like an alias.
205    is_canonical: bool,
206    /// How the canonical path was obtained.
207    form: CanonicalForm,
208}
209
210/// How trustworthy the canonical path handed to
211/// [`PublishedDiagnosticsUri::from_canonical_path`] is.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub(crate) enum CanonicalForm {
214    /// Produced by canonicalizing the filesystem (now or earlier).
215    Resolved,
216    /// The filesystem could not be asked; the published spelling stood in.
217    /// Never reported as canonical, so a stand-in cannot pass for the real
218    /// key in the alias index.
219    Fallback,
220}
221
222impl PublishedDiagnosticsUri {
223    /// Pairs `published` with its already-resolved `canonical_path`, checking
224    /// the result against `roots`.
225    ///
226    /// Returns `None` for a canonical path that cannot be encoded as a URI or
227    /// that lies outside every root (for example a symlink pointing out of
228    /// the workspace). Resolving the path is
229    /// [`PublishedPathResolver`](super::PublishedPathResolver)'s job.
230    pub(super) fn from_canonical_path(
231        published: &lsp_types::Uri,
232        published_path: &Path,
233        canonical_path: &Path,
234        form: CanonicalForm,
235        roots: &WorkspaceRoots,
236    ) -> Option<Self> {
237        let canonical = super::try_path_to_uri(canonical_path)?;
238        roots.contains_canonical(canonical_path).then(|| Self {
239            source: published.clone(),
240            canonical,
241            is_canonical: form == CanonicalForm::Resolved
242                && same_path(published_path, canonical_path),
243            form,
244        })
245    }
246
247    #[cfg(test)]
248    pub(crate) fn for_test(source: lsp_types::Uri, canonical: lsp_types::Uri) -> Self {
249        Self::for_test_with_form(source, canonical, CanonicalForm::Resolved)
250    }
251
252    #[cfg(test)]
253    pub(crate) fn for_test_with_form(
254        source: lsp_types::Uri,
255        canonical: lsp_types::Uri,
256        form: CanonicalForm,
257    ) -> Self {
258        let is_canonical = source == canonical && form == CanonicalForm::Resolved;
259        Self {
260            source,
261            canonical,
262            is_canonical,
263            form,
264        }
265    }
266
267    /// Whether the canonical path came from the filesystem or stood in for it.
268    pub(crate) const fn form(&self) -> CanonicalForm {
269        self.form
270    }
271
272    /// Whether the server published under the canonical spelling of the path.
273    pub(crate) const fn is_canonical(&self) -> bool {
274        self.is_canonical
275    }
276
277    /// The URI exactly as the server published it.
278    pub(crate) const fn source(&self) -> &lsp_types::Uri {
279        &self.source
280    }
281
282    /// The canonical URI of the published file.
283    pub(crate) const fn canonical(&self) -> &lsp_types::Uri {
284        &self.canonical
285    }
286}
287
288/// Path equality as the platform's filesystem sees the spelling: drive
289/// letters and names are case-insensitive on Windows only.
290fn same_path(a: &Path, b: &Path) -> bool {
291    if cfg!(windows) {
292        a.as_os_str()
293            .to_string_lossy()
294            .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy())
295    } else {
296        a == b
297    }
298}
299
300/// A client resource URI resolved against the workspace roots.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub(crate) struct ResolvedResource {
303    /// Canonical filesystem path the URI refers to.
304    pub(crate) path: PathBuf,
305    /// URI re-encoded from [`Self::path`].
306    pub(crate) uri: DiagnosticsResourceUri,
307}
308
309impl DiagnosticsResourceUri {
310    /// Parses `raw`, validates the path against `roots` (canonicalizing it)
311    /// and re-encodes the canonical path.
312    ///
313    /// Touches the filesystem; call from a blocking context when resolving
314    /// many URIs.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`crate::Error::ResourceUri`] when `raw` is not a well-formed
319    /// `lsp-diagnostics:///` URI, the workspace validation error when the
320    /// path is missing or outside `roots`, or [`crate::Error::PathToUri`] when
321    /// the canonical path cannot be encoded again.
322    pub(crate) fn resolve(
323        raw: &str,
324        roots: &WorkspaceRoots,
325    ) -> crate::error::Result<ResolvedResource> {
326        let parsed = parse_uri(raw)?;
327        // `canonicalize` yields a `\\?\` verbatim path on Windows, which LSP
328        // servers never publish; `dunce` strips it where that is safe.
329        let validated = roots.validate_blocking(&parsed)?;
330        let path = dunce::simplified(validated.as_path()).to_path_buf();
331        let uri = Self(make_uri(&path).map_err(|_| crate::Error::PathToUri(path.clone()))?);
332        Ok(ResolvedResource { path, uri })
333    }
334
335    /// The key the pump derives for a file an LSP server published
336    /// diagnostics for: the canonical form, so it matches the key
337    /// [`Self::resolve`] derives from a client URI for the same file.
338    pub(crate) fn for_published(published: &PublishedDiagnosticsUri) -> Option<Self> {
339        Self::for_canonical_file(&published.canonical)
340    }
341
342    /// The key for a file the tracker holds, given its canonical LSP URI.
343    pub(crate) fn for_canonical_file(file: &lsp_types::Uri) -> Option<Self> {
344        make_uri(&uri_to_path(file)?).ok().map(Self)
345    }
346
347    /// The wire form of the URI.
348    pub(crate) fn as_str(&self) -> &str {
349        &self.0
350    }
351
352    #[cfg(test)]
353    pub(crate) fn for_test(uri: &str) -> Self {
354        Self(uri.to_owned())
355    }
356}
357
358impl fmt::Display for DiagnosticsResourceUri {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        f.write_str(&self.0)
361    }
362}
363
364impl From<DiagnosticsResourceUri> for String {
365    fn from(uri: DiagnosticsResourceUri) -> Self {
366        uri.0
367    }
368}
369
370/// Internal state guarded by [`ResourceSubscriptions`]'s `RwLock`.
371#[derive(Debug, Default)]
372struct SubscriptionState {
373    /// Canonical resource URIs currently subscribed -- what the diagnostics
374    /// pump checks against.
375    canonical: HashSet<DiagnosticsResourceUri>,
376    /// Client-supplied ("raw") URI -> canonical URI, recorded at subscribe
377    /// time for entries where the two differ (symlink, macOS `/var` vs
378    /// `/private/var`, ...). Lets a later `unsubscribe` for the same raw URI
379    /// still resolve to the right entry even if canonicalizing it at
380    /// unsubscribe time fails, e.g. because the file was deleted since
381    /// subscribing (#499).
382    aliases: HashMap<String, DiagnosticsResourceUri>,
383}
384
385/// Tracks which MCP resource URIs the client has subscribed to.
386///
387/// The hot read path (pump tasks checking before sending notifications) uses
388/// a `RwLock` so concurrent readers do not block each other.
389#[derive(Debug)]
390pub(crate) struct ResourceSubscriptions(RwLock<SubscriptionState>);
391
392impl Default for ResourceSubscriptions {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398impl ResourceSubscriptions {
399    /// Create an empty subscription set.
400    #[must_use]
401    pub fn new() -> Self {
402        Self(RwLock::new(SubscriptionState::default()))
403    }
404
405    /// Create a set pre-populated with `uris`, for request-scoped sessions
406    /// whose subscriptions are fixed up front (`subscriptions/listen`).
407    pub(crate) fn from_canonical(uris: impl IntoIterator<Item = DiagnosticsResourceUri>) -> Self {
408        Self(RwLock::new(SubscriptionState {
409            canonical: uris.into_iter().collect(),
410            aliases: HashMap::new(),
411        }))
412    }
413
414    /// Add a URI to the subscription set.
415    ///
416    /// Returns `Ok(true)` if newly inserted, `Ok(false)` if already present.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`SubscriptionError::LimitReached`] if the set has already
421    /// reached [`MAX_SUBSCRIPTIONS`] and `uri` is not already a member.
422    pub(crate) async fn subscribe(
423        &self,
424        uri: DiagnosticsResourceUri,
425    ) -> Result<bool, SubscriptionError> {
426        let mut state = self.0.write().await;
427        if !state.canonical.contains(&uri) && state.canonical.len() >= MAX_SUBSCRIPTIONS {
428            return Err(SubscriptionError::LimitReached);
429        }
430        Ok(state.canonical.insert(uri))
431    }
432
433    /// Record that `raw_uri` (the client-supplied URI, before
434    /// canonicalization) currently corresponds to `canonical_uri`, so a
435    /// later [`Self::unsubscribe`] for the same raw URI still resolves even
436    /// if canonicalization fails by then (#499). A no-op when the two are
437    /// equal, or when `canonical_uri` is not (or is no longer) an actual
438    /// subscribed entry -- the latter also closes a race against a
439    /// concurrent [`Self::unsubscribe`] landing between a caller's own
440    /// `subscribe`/`record_alias` pair.
441    ///
442    /// Drops any existing alias that already points at `canonical_uri`
443    /// before inserting the new one, so at most one alias is kept per
444    /// canonical entry. This keeps the alias map's size structurally bounded
445    /// by the canonical set's size (itself capped at [`MAX_SUBSCRIPTIONS`] by
446    /// [`Self::subscribe`]), instead of an independent bound: without this,
447    /// re-subscribing under many distinct raw encodings of the same
448    /// already-subscribed file (each a no-op against the canonical set, so
449    /// never gated by the subscribe cap) could otherwise exhaust an
450    /// independent alias-count bound on a single file, starving aliases for
451    /// every other subscription.
452    ///
453    /// Residual (#499): one-alias-per-canonical narrows but does not fully
454    /// eliminate the leak this exists to close. A file subscribed under two
455    /// distinct raw URIs, then deleted, then unsubscribed via the
456    /// non-latest raw form still leaks one slot -- self-healing the moment
457    /// the client instead unsubscribes via the latest recorded form.
458    pub(crate) async fn record_alias(
459        &self,
460        raw_uri: String,
461        canonical_uri: &DiagnosticsResourceUri,
462    ) {
463        if raw_uri == canonical_uri.as_str() {
464            return;
465        }
466        let mut state = self.0.write().await;
467        if !state.canonical.contains(canonical_uri) {
468            return;
469        }
470        state.aliases.retain(|_, c| c != canonical_uri);
471        state.aliases.insert(raw_uri, canonical_uri.clone());
472    }
473
474    /// Check whether the subscription set is empty.
475    ///
476    /// Used as a fast path in the diagnostics pump to skip URI construction
477    /// when no client has subscribed yet.
478    pub async fn is_empty(&self) -> bool {
479        self.0.read().await.canonical.is_empty()
480    }
481
482    /// Remove a subscription from the set.
483    ///
484    /// A recorded alias for `raw` (see `Self::record_alias`, crate-private)
485    /// wins, so a symlink retargeted since subscribing still removes the entry
486    /// it was subscribed under. Otherwise `canonical` (when the caller could
487    /// canonicalize the client URI) is tried, then `raw` verbatim.
488    ///
489    /// Returns the canonical URI that was removed, or `None` if no
490    /// subscription matched -- the caller needs it to purge the same entry
491    /// from any state keyed by canonical URI when `raw` was an alias.
492    pub(crate) async fn unsubscribe(
493        &self,
494        canonical: Option<&DiagnosticsResourceUri>,
495        raw: &str,
496    ) -> Option<DiagnosticsResourceUri> {
497        let mut state = self.0.write().await;
498        let removed = state
499            .aliases
500            .remove(raw)
501            .and_then(|aliased| state.canonical.take(&aliased))
502            .or_else(|| canonical.and_then(|c| state.canonical.take(c)))
503            // Lookup key only: `take` returns the stored entry, never this value.
504            .or_else(|| {
505                state
506                    .canonical
507                    .take(&DiagnosticsResourceUri(raw.to_owned()))
508            });
509        if let Some(removed) = &removed {
510            state.aliases.retain(|_, c| c != removed);
511        }
512        drop(state);
513        removed
514    }
515
516    /// A copy of the canonical subscription set, so a caller can evaluate a
517    /// predicate on each URI without holding the subscription lock.
518    pub(crate) async fn snapshot(&self) -> Vec<DiagnosticsResourceUri> {
519        self.0.read().await.canonical.iter().cloned().collect()
520    }
521
522    /// Check if a URI is currently subscribed.
523    pub(crate) async fn contains(&self, uri: &DiagnosticsResourceUri) -> bool {
524        self.0.read().await.canonical.contains(uri)
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use std::assert_matches;
531
532    use super::*;
533    #[cfg(unix)]
534    use crate::test_lsp::client_path;
535
536    fn roots_of(root: &Path) -> WorkspaceRoots {
537        WorkspaceRoots::from_paths(&[root.to_path_buf()]).unwrap()
538    }
539
540    // ------------------------------------------------------------------
541    // URI codec
542    // ------------------------------------------------------------------
543
544    #[cfg(windows)]
545    #[test]
546    fn test_make_uri_encodes_a_windows_rooted_path_without_a_drive() {
547        let uri = make_uri(Path::new(r"\ws\main.rs")).unwrap();
548        assert_eq!(uri, "lsp-diagnostics:///ws/main.rs");
549    }
550
551    #[test]
552    fn test_make_uri_rejects_relative_path() {
553        let result = make_uri(Path::new("relative/path.rs"));
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn test_parse_uri_rejects_wrong_scheme() {
559        let result = parse_uri("file:///home/user/main.rs");
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn test_parse_uri_rejects_decoded_nul() {
565        let drive = if cfg!(windows) { "C:/" } else { "" };
566        let result = parse_uri(&format!("lsp-diagnostics:///{drive}ws/a%00b.rs"));
567        assert_matches!(result, Err(ResourceUriError::ClientPath(_)));
568    }
569
570    #[test]
571    fn test_parse_uri_rejects_http_scheme() {
572        let result = parse_uri("https://example.com/file.rs");
573        assert!(result.is_err());
574    }
575
576    #[cfg(unix)]
577    #[test]
578    fn test_make_uri_simple_path() {
579        let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
580        assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
581    }
582
583    #[cfg(unix)]
584    #[test]
585    fn test_make_uri_scheme_prefix() {
586        let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
587        assert!(uri.starts_with("lsp-diagnostics:///"));
588    }
589
590    #[cfg(unix)]
591    #[test]
592    fn test_parse_uri_simple() {
593        let path = PathBuf::from("/home/user/main.rs");
594        let uri = make_uri(&path).unwrap();
595        let recovered = parse_uri(&uri).unwrap();
596        assert_eq!(recovered.as_path(), path);
597    }
598
599    /// Round-trip: paths with spaces, unicode, `%`, `?`, `#`.
600    #[cfg(unix)]
601    #[test]
602    fn test_round_trip_special_chars() {
603        let paths = [
604            "/home/user/my file.rs",
605            "/tmp/café/main.rs",
606            "/data/100%/test.rs",
607            "/workspace/query?param/file.rs",
608            "/repo/branch#fragment/src.rs",
609            "/путь/к/файлу.rs",
610        ];
611
612        for raw in &paths {
613            let path = PathBuf::from(raw);
614            let uri = make_uri(&path).expect(raw);
615            assert!(
616                uri.starts_with("lsp-diagnostics:///"),
617                "URI should start with correct scheme: {uri}"
618            );
619            let recovered = parse_uri(&uri).expect(&uri);
620            assert_eq!(recovered.as_path(), path, "Round-trip failed for: {raw}");
621        }
622    }
623
624    /// Snapshot test: verify the on-wire form uses three slashes and percent-encoding.
625    #[cfg(unix)]
626    #[test]
627    fn test_wire_format_percent_encoded() {
628        let path = Path::new("/home/user/my file.rs");
629        let uri = make_uri(path).unwrap();
630        // Space must be percent-encoded as %20
631        assert!(uri.contains("%20"), "Expected %20 in: {uri}");
632        assert!(uri.starts_with("lsp-diagnostics:///"));
633    }
634
635    /// #265 regression: all seven RFC 3986 §2.2 "other reserved" characters
636    /// must be percent-encoded in `lsp-diagnostics://` URIs, same as
637    /// `file://` URIs from `try_path_to_uri` (see
638    /// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`
639    /// in `state.rs`). `{`, `}`, and backtick are already encoded by the
640    /// `url` crate on serialization; `[`, `]`, `^`, `|` are handled
641    /// explicitly by `encode_rfc3986_path_chars`.
642    #[cfg(unix)]
643    #[test]
644    fn test_make_uri_percent_encodes_reserved_chars() {
645        let path = Path::new("/home/user/test[]^|{}`.ts");
646        let uri = make_uri(path).unwrap();
647
648        for (raw, encoded) in [
649            ('[', "%5B"),
650            (']', "%5D"),
651            ('^', "%5E"),
652            ('|', "%7C"),
653            ('{', "%7B"),
654            ('}', "%7D"),
655            ('`', "%60"),
656        ] {
657            assert!(
658                uri.contains(encoded),
659                "expected {raw:?} to be percent-encoded as {encoded} in {uri}"
660            );
661        }
662        assert!(
663            !uri.contains(['[', ']', '^', '|', '{', '}', '`']),
664            "no raw reserved characters should remain in {uri}"
665        );
666        assert_eq!(parse_uri(&uri).unwrap().as_path(), path);
667    }
668
669    // ------------------------------------------------------------------
670    // DiagnosticsResourceUri
671    // ------------------------------------------------------------------
672
673    use crate::bridge::resolve_one;
674    use crate::test_lsp::{diagnostics_uri as u, workspace_with_main_rs as workspace};
675
676    #[test]
677    fn test_resolve_rejects_foreign_scheme() {
678        let (_dir, root, _file) = workspace();
679        let err = DiagnosticsResourceUri::resolve(
680            "file:///tmp/main.rs",
681            &WorkspaceRoots::from_paths(&[root]).unwrap(),
682        )
683        .unwrap_err();
684        assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
685    }
686
687    #[test]
688    fn test_resolve_rejects_non_empty_authority() {
689        let (_dir, root, _file) = workspace();
690        let err = DiagnosticsResourceUri::resolve(
691            "lsp-diagnostics://host/main.rs",
692            &WorkspaceRoots::from_paths(&[root]).unwrap(),
693        )
694        .unwrap_err();
695        assert_matches!(err, crate::Error::ResourceUri(_), "got {err:?}");
696    }
697
698    /// #571: a subscribe URI naming a file through the configured symlinked
699    /// spelling of the root resolves to the canonical file.
700    #[cfg(unix)]
701    #[test]
702    fn test_resolve_admits_configured_symlink_spelling() {
703        let (_dir, root, file) = workspace();
704        let link = root.join("link_to_root");
705        std::os::unix::fs::symlink(&root, &link).unwrap();
706        let roots = roots_of(&link);
707
708        let raw = make_uri(&link.join(file.strip_prefix(&root).unwrap())).unwrap();
709        let resolved = DiagnosticsResourceUri::resolve(&raw, &roots).unwrap();
710
711        assert_eq!(resolved.path, file);
712    }
713
714    /// #575: a subscribe URI whose path decodes to a NUL byte is an invalid
715    /// URI, which classifies as caller-fault.
716    #[test]
717    fn test_resolve_nul_uri_is_invalid_params() {
718        let (_dir, root, _file) = workspace();
719
720        let err =
721            DiagnosticsResourceUri::resolve("lsp-diagnostics:///ws/a%00b.rs", &roots_of(&root))
722                .unwrap_err();
723
724        assert_eq!(
725            err.mcp_error_kind(),
726            crate::error::McpErrorKind::InvalidParams
727        );
728    }
729
730    #[test]
731    fn test_resolve_rejects_path_outside_workspace() {
732        let (_dir, root, _file) = workspace();
733        let (_other_dir, _other_root, other_file) = workspace();
734        let raw = make_uri(&other_file).unwrap();
735        assert!(
736            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
737                .is_err()
738        );
739    }
740
741    #[test]
742    fn test_resolve_rejects_missing_file() {
743        let (_dir, root, _file) = workspace();
744        let raw = make_uri(&root.join("missing.rs")).unwrap();
745        assert!(
746            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
747                .is_err()
748        );
749    }
750
751    #[test]
752    fn test_resolve_yields_canonical_path_and_uri() {
753        let (_dir, root, file) = workspace();
754        let raw = make_uri(&file).unwrap();
755        let resolved =
756            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
757                .unwrap();
758        assert_eq!(resolved.path, file);
759        assert_eq!(resolved.uri.as_str(), raw);
760    }
761
762    #[cfg(unix)]
763    #[test]
764    fn test_resolve_canonicalizes_symlinked_path() {
765        let (_dir, root, file) = workspace();
766        let link = root.join("link.rs");
767        std::os::unix::fs::symlink(&file, &link).unwrap();
768        let raw = make_uri(&link).unwrap();
769        let resolved =
770            DiagnosticsResourceUri::resolve(&raw, &WorkspaceRoots::from_paths(&[root]).unwrap())
771                .unwrap();
772        assert_eq!(resolved.uri.as_str(), make_uri(&file).unwrap());
773    }
774
775    #[tokio::test]
776    async fn test_for_published_matches_resolve_for_same_file() {
777        let (_dir, root, file) = workspace();
778        let roots = WorkspaceRoots::from_paths(&[root]).unwrap();
779        let resolved = DiagnosticsResourceUri::resolve(&make_uri(&file).unwrap(), &roots).unwrap();
780        let published = crate::bridge::path_to_uri(&file).unwrap();
781        let published = resolve_one(&published, &roots).await.unwrap();
782        assert_eq!(
783            DiagnosticsResourceUri::for_published(&published),
784            Some(resolved.uri)
785        );
786    }
787
788    #[tokio::test]
789    async fn test_published_resolve_rejects_non_file_uri() {
790        let (_dir, root, _file) = workspace();
791        let uri = lsp_types::Uri::from("untitled:Untitled-1");
792        assert_eq!(resolve_one(&uri, &roots_of(&root)).await, None);
793    }
794
795    /// Percent-encoding the path must not make a canonical spelling look like
796    /// an alias.
797    #[tokio::test]
798    async fn test_published_resolve_percent_encoded_spelling_is_canonical() {
799        let (_dir, root, file) = workspace();
800        let canonical = crate::bridge::path_to_uri(&file).unwrap();
801        let encoded = lsp_types::Uri::from(canonical.as_ref().replace("main.rs", "main%2Ers"));
802        assert_ne!(encoded, canonical);
803
804        let published = resolve_one(&encoded, &roots_of(&root)).await.unwrap();
805
806        assert!(published.is_canonical());
807        assert_eq!(published.canonical(), &canonical);
808    }
809
810    #[tokio::test]
811    async fn test_published_resolve_deleted_file_keeps_canonical_form() {
812        let (_dir, root, file) = workspace();
813        std::fs::remove_file(&file).unwrap();
814        let uri = crate::bridge::path_to_uri(&file).unwrap();
815
816        let published = resolve_one(&uri, &roots_of(&root)).await.unwrap();
817
818        assert_eq!(published.canonical(), &uri);
819    }
820
821    #[cfg(unix)]
822    #[tokio::test]
823    async fn test_published_resolve_maps_symlink_to_canonical_key() {
824        let (_dir, root, file) = workspace();
825        let link = root.join("link.rs");
826        std::os::unix::fs::symlink(&file, &link).unwrap();
827        let link_uri = crate::bridge::path_to_uri(&link).unwrap();
828
829        let published = resolve_one(&link_uri, &roots_of(&root)).await.unwrap();
830
831        assert_eq!(published.source(), &link_uri);
832        assert_eq!(
833            published.canonical(),
834            &crate::bridge::path_to_uri(&file).unwrap()
835        );
836        let client_side = crate::bridge::Translator::cached_diagnostics_uri(
837            &roots_of(&root),
838            &client_path(&link),
839        )
840        .await
841        .unwrap();
842        assert_eq!(published.canonical(), &client_side);
843    }
844
845    #[cfg(unix)]
846    #[tokio::test]
847    async fn test_published_resolve_drops_symlink_pointing_outside() {
848        let (_dir, root, _file) = workspace();
849        let outside = tempfile::TempDir::new().unwrap();
850        std::fs::write(outside.path().join("x.rs"), "").unwrap();
851        let link = root.join("out.rs");
852        std::os::unix::fs::symlink(outside.path().join("x.rs"), &link).unwrap();
853        let link_uri = crate::bridge::path_to_uri(&link).unwrap();
854
855        assert_eq!(resolve_one(&link_uri, &roots_of(&root)).await, None);
856    }
857
858    // ------------------------------------------------------------------
859    // ResourceSubscriptions
860    // ------------------------------------------------------------------
861
862    #[tokio::test]
863    async fn test_subscribe_and_contains() {
864        let subs = ResourceSubscriptions::new();
865        let uri = u("lsp-diagnostics:///home/user/main.rs");
866
867        assert!(!subs.contains(&uri).await);
868        assert!(subs.subscribe(uri.clone()).await.unwrap());
869        assert!(subs.contains(&uri).await);
870    }
871
872    #[tokio::test]
873    async fn test_from_canonical_prepopulates() {
874        let uri = u("lsp-diagnostics:///tmp/file.rs");
875        let subs = ResourceSubscriptions::from_canonical([uri.clone()]);
876        assert!(subs.contains(&uri).await);
877        assert!(!subs.is_empty().await);
878    }
879
880    #[tokio::test]
881    async fn test_subscribe_duplicate_returns_false() {
882        let subs = ResourceSubscriptions::new();
883        let uri = u("lsp-diagnostics:///tmp/file.rs");
884        assert!(subs.subscribe(uri.clone()).await.unwrap());
885        assert!(!subs.subscribe(uri).await.unwrap());
886    }
887
888    #[tokio::test]
889    async fn test_unsubscribe() {
890        let subs = ResourceSubscriptions::new();
891        let uri = u("lsp-diagnostics:///tmp/file.rs");
892        subs.subscribe(uri.clone()).await.unwrap();
893        assert_eq!(
894            subs.unsubscribe(Some(&uri), uri.as_str()).await,
895            Some(uri.clone())
896        );
897        assert!(!subs.contains(&uri).await);
898    }
899
900    #[tokio::test]
901    async fn test_unsubscribe_nonexistent_returns_none() {
902        let subs = ResourceSubscriptions::new();
903        let missing = u("lsp-diagnostics:///nonexistent.rs");
904        assert!(
905            subs.unsubscribe(Some(&missing), missing.as_str())
906                .await
907                .is_none()
908        );
909    }
910
911    /// Without a canonical URI (canonicalization failed) the raw string still
912    /// removes an entry whose canonical form equals it.
913    #[tokio::test]
914    async fn test_unsubscribe_without_canonical_matches_raw_verbatim() {
915        let subs = ResourceSubscriptions::new();
916        let uri = u("lsp-diagnostics:///tmp/file.rs");
917        subs.subscribe(uri.clone()).await.unwrap();
918        assert_eq!(subs.unsubscribe(None, uri.as_str()).await, Some(uri));
919    }
920
921    /// #499: a stale raw URI recorded via `record_alias` must still resolve
922    /// to the canonical entry `subscribe` created, even though the raw and
923    /// canonical strings differ (e.g. a symlink or macOS `/var` vs
924    /// `/private/var`).
925    #[tokio::test]
926    async fn test_unsubscribe_resolves_recorded_alias() {
927        let subs = ResourceSubscriptions::new();
928        let raw = "lsp-diagnostics:///var/tmp/file.rs";
929        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
930
931        subs.subscribe(canonical.clone()).await.unwrap();
932        subs.record_alias(raw.to_owned(), &canonical).await;
933        assert!(subs.contains(&canonical).await);
934
935        assert_eq!(subs.unsubscribe(None, raw).await, Some(canonical.clone()));
936        assert!(!subs.contains(&canonical).await);
937    }
938
939    /// A re-canonicalization that now yields a different URI (symlink
940    /// retargeted since subscribing) falls back to the recorded alias.
941    #[tokio::test]
942    async fn test_unsubscribe_alias_wins_over_retargeted_canonical() {
943        let subs = ResourceSubscriptions::new();
944        let raw = "lsp-diagnostics:///link.rs";
945        let (a, b) = (u("lsp-diagnostics:///a.rs"), u("lsp-diagnostics:///b.rs"));
946        subs.subscribe(a.clone()).await.unwrap();
947        subs.subscribe(b.clone()).await.unwrap();
948        subs.record_alias(raw.to_owned(), &a).await;
949
950        assert_eq!(subs.unsubscribe(Some(&b), raw).await, Some(a.clone()));
951        assert!(!subs.contains(&a).await);
952        assert!(subs.contains(&b).await, "the retargeted entry must survive");
953    }
954
955    #[tokio::test]
956    async fn test_unsubscribe_falls_back_to_alias_when_canonical_changed() {
957        let subs = ResourceSubscriptions::new();
958        let raw = "lsp-diagnostics:///link.rs";
959        let original = u("lsp-diagnostics:///old.rs");
960        let retargeted = u("lsp-diagnostics:///new.rs");
961
962        subs.subscribe(original.clone()).await.unwrap();
963        subs.record_alias(raw.to_owned(), &original).await;
964
965        assert_eq!(
966            subs.unsubscribe(Some(&retargeted), raw).await,
967            Some(original.clone())
968        );
969        assert!(!subs.contains(&original).await);
970    }
971
972    /// `record_alias` is a no-op when the raw and canonical URIs are equal
973    /// (the common case), so it never grows the alias map for entries that
974    /// don't need it.
975    #[tokio::test]
976    async fn test_record_alias_noop_when_raw_equals_canonical() {
977        let subs = ResourceSubscriptions::new();
978        let uri = u("lsp-diagnostics:///tmp/file.rs");
979        subs.subscribe(uri.clone()).await.unwrap();
980        subs.record_alias(uri.as_str().to_owned(), &uri).await;
981
982        assert_eq!(subs.unsubscribe(Some(&uri), uri.as_str()).await, Some(uri));
983    }
984
985    /// Unsubscribing under the canonical URI directly must also clear any
986    /// aliases that pointed at it, so the alias map does not accumulate
987    /// stale entries for already-removed subscriptions.
988    #[tokio::test]
989    async fn test_unsubscribe_by_canonical_clears_stale_aliases() {
990        let subs = ResourceSubscriptions::new();
991        let raw = "lsp-diagnostics:///var/tmp/file.rs";
992        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
993
994        subs.subscribe(canonical.clone()).await.unwrap();
995        subs.record_alias(raw.to_owned(), &canonical).await;
996
997        assert!(subs.unsubscribe(Some(&canonical), "other").await.is_some());
998        assert!(subs.unsubscribe(None, raw).await.is_none());
999    }
1000
1001    /// #499 site fix (impl-critic C1): only the most recently recorded alias
1002    /// for a given canonical URI is kept, so the alias map's size stays
1003    /// structurally tied to the (already `MAX_SUBSCRIPTIONS`-capped)
1004    /// canonical set's size.
1005    #[tokio::test]
1006    async fn test_record_alias_keeps_only_latest_alias_per_canonical() {
1007        let subs = ResourceSubscriptions::new();
1008        let canonical = u("lsp-diagnostics:///file.rs");
1009        subs.subscribe(canonical.clone()).await.unwrap();
1010
1011        let raws = [
1012            "lsp-diagnostics:///%66ile.rs",
1013            "lsp-diagnostics:///fil%65.rs",
1014            "lsp-diagnostics:///file%2Ers",
1015        ];
1016        for raw in raws {
1017            subs.record_alias(raw.to_owned(), &canonical).await;
1018        }
1019
1020        for raw in &raws[..raws.len() - 1] {
1021            assert!(subs.unsubscribe(None, raw).await.is_none());
1022        }
1023        let latest = raws.last().unwrap();
1024        assert!(subs.unsubscribe(None, latest).await.is_some());
1025        assert!(!subs.contains(&canonical).await);
1026    }
1027
1028    /// #499 site fix (impl-critic C3): `record_alias` is a no-op when its
1029    /// canonical URI is not (or is no longer) an actual subscribed entry.
1030    /// Subscribing `canonical` *after* the no-op call isolates the guard: had
1031    /// it recorded the alias anyway, `raw` would now resolve to the entry.
1032    #[tokio::test]
1033    async fn test_record_alias_noop_for_unsubscribed_canonical() {
1034        let subs = ResourceSubscriptions::new();
1035        let raw = "lsp-diagnostics:///var/tmp/file.rs";
1036        let canonical = u("lsp-diagnostics:///private/var/tmp/file.rs");
1037
1038        subs.record_alias(raw.to_owned(), &canonical).await;
1039
1040        subs.subscribe(canonical.clone()).await.unwrap();
1041        assert!(subs.unsubscribe(None, raw).await.is_none());
1042        assert!(subs.unsubscribe(Some(&canonical), raw).await.is_some());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_subscribe_cap_exceeded() {
1047        let subs = ResourceSubscriptions::new();
1048        for i in 0..MAX_SUBSCRIPTIONS {
1049            subs.subscribe(u(&format!("lsp-diagnostics:///file{i}.rs")))
1050                .await
1051                .unwrap();
1052        }
1053        let result = subs.subscribe(u("lsp-diagnostics:///overflow.rs")).await;
1054        assert_eq!(result, Err(SubscriptionError::LimitReached));
1055    }
1056}