Skip to main content

deps_core/
path_segment.rs

1//! A URL path segment checked, at construction, not to be a dot-segment (`.`/`..`).
2//!
3//! [`is_dot_segment`] and
4//! [`dot_segment_rejection_error`] already
5//! gate every registry-fetch URL builder in this workspace against the #341/#349/#365 defect
6//! class, but only by convention: each ecosystem crate calls the predicate itself, right
7//! before calling a URL builder that still accepts a raw `&str`, so a new call site can
8//! forget the check and the compiler will not notice (#341/#365 recurred five times this
9//! way). [`SafePathSegment`] closes that gap by making the checked segment the *only* value a
10//! fetch-URL builder's signature can accept, so the guard becomes unforgettable rather than
11//! merely documented.
12
13use crate::error::Result;
14use crate::lsp_helpers::{dot_segment_rejection_error, is_dot_segment};
15
16/// Error returned by [`SafePathSegment::new`] when `segment` is exactly `.` or `..`.
17///
18/// Borrows the rejected segment rather than owning it: every caller either reads it
19/// immediately (the rejection is already fatal to the current operation) or discards it in
20/// favor of [`SafePathSegment::checked_or_reject`]'s own registry-error construction, so an
21/// owned `String` would only add an allocation on an already-adversarial-input path.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
23#[error("path segment {segment:?} is a dot-segment (\".\" or \"..\")")]
24pub struct DotSegmentError<'a> {
25    segment: &'a str,
26}
27
28impl<'a> DotSegmentError<'a> {
29    /// The rejected segment, for building a registry-specific
30    /// [`DepsError`](crate::error::DepsError) via
31    /// [`dot_segment_rejection_error`].
32    #[must_use]
33    pub const fn segment(&self) -> &'a str {
34        self.segment
35    }
36}
37
38/// A single URL path segment, checked at construction not to be exactly `.` or `..`.
39///
40/// A percent-encoded `.`/`..` segment (`.` is an RFC 3986 unreserved character, so encoding
41/// leaves it unchanged) still gets collapsed by a URL parser's dot-segment normalization
42/// (RFC 3986 §5.2.4) once interpolated into a request URL, letting it retarget the request
43/// off the intended path prefix. A registry-fetch URL builder that takes `SafePathSegment`
44/// instead of a raw `&str`/`String` cannot be called with an unchecked value — the dot-segment
45/// guard is enforced by the type system rather than by caller convention.
46///
47/// Borrows rather than owns: every caller in this workspace validates a segment immediately
48/// before building a URL from it and never stores the checked value, so an owned `String`
49/// would only add an allocation. `Copy` for the same reason — passing it to more than one URL
50/// builder (e.g. NuGet's flat-container and registration-index URLs, built from the same
51/// package name) needs no explicit `.clone()`.
52///
53/// # Examples
54///
55/// ```
56/// use deps_core::path_segment::SafePathSegment;
57///
58/// assert!(SafePathSegment::new("left-pad").is_ok());
59/// assert!(SafePathSegment::new(".").is_err());
60/// assert!(SafePathSegment::new("..").is_err());
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub struct SafePathSegment<'a>(&'a str);
64
65impl<'a> SafePathSegment<'a> {
66    /// # Errors
67    ///
68    /// Returns [`DotSegmentError`] if `segment` is exactly `.` or `..`.
69    pub fn new(segment: &'a str) -> std::result::Result<Self, DotSegmentError<'a>> {
70        if is_dot_segment(segment) {
71            Err(DotSegmentError { segment })
72        } else {
73            Ok(Self(segment))
74        }
75    }
76
77    /// Validates `segment`, converting a rejection directly into the registry-specific
78    /// [`DepsError::PackageNotFound`](crate::error::DepsError::PackageNotFound) via
79    /// [`dot_segment_rejection_error`] — the
80    /// single call every ecosystem crate's own dot-segment gate (`deps-composer`, `deps-dart`,
81    /// `deps-npm`, `deps-nuget`) used to hand-roll separately as a `SafePathSegment::new(...)`
82    /// + `.map_err(...)` pair (#1514 item 4 follow-up).
83    ///
84    /// `name` is the full, possibly compound identifier surfaced in the resulting log/error —
85    /// not necessarily `segment` itself: a compound name (Composer's `vendor/package`, npm's
86    /// `@scope/pkg`) is split into more than one segment before each is checked here, and the
87    /// rejection should report the whole declared name a user recognizes, not just whichever
88    /// half happened to be the offending one.
89    ///
90    /// # Errors
91    ///
92    /// Returns `DepsError::PackageNotFound` if `segment` is exactly `.` or `..`.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use deps_core::path_segment::SafePathSegment;
98    ///
99    /// assert!(SafePathSegment::checked_or_reject("left-pad", "left-pad", "example URL", "npm").is_ok());
100    /// assert!(SafePathSegment::checked_or_reject("..", "@a/..", "example URL", "npm").is_err());
101    /// ```
102    pub fn checked_or_reject(
103        segment: &'a str,
104        name: &str,
105        context: &str,
106        registry: &'static str,
107    ) -> Result<Self> {
108        Self::new(segment)
109            .map_err(|_| dot_segment_rejection_error("is_dot_segment", context, name, registry))
110    }
111
112    /// The validated segment's underlying string.
113    #[must_use]
114    pub const fn as_str(&self) -> &'a str {
115        self.0
116    }
117}
118
119impl std::fmt::Display for SafePathSegment<'_> {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.write_str(self.0)
122    }
123}
124
125/// One or two validated path segments for a fetch URL built from a package name.
126///
127/// Covers both a bare identifier and a `<prefix>/<name>`-shaped one — the split shape shared
128/// by npm's `@scope/pkg` and Composer's `vendor/package` registry-fetch URLs.
129///
130/// Each ecosystem keeps its own parsing (npm strips a leading `@` before splitting; Composer
131/// splits on the first `/` with no scope marker), so this type carries only the already-split,
132/// already-validated result — never the splitting logic itself.
133///
134/// # Examples
135///
136/// ```
137/// use deps_core::path_segment::{SafePathSegment, SegmentedPathName};
138///
139/// let bare = SegmentedPathName::Single(SafePathSegment::new("left-pad").unwrap());
140/// let prefixed = SegmentedPathName::Prefixed(
141///     SafePathSegment::new("monolog").unwrap(),
142///     SafePathSegment::new("monolog").unwrap(),
143/// );
144/// assert!(matches!(bare, SegmentedPathName::Single(_)));
145/// assert!(matches!(prefixed, SegmentedPathName::Prefixed(_, _)));
146/// ```
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub enum SegmentedPathName<'a> {
149    /// A single, unprefixed path segment (Composer's fallback bare form, npm's unscoped
150    /// name).
151    Single(SafePathSegment<'a>),
152    /// A `<prefix>/<name>` pair, each independently validated (npm's `@scope/pkg`,
153    /// Composer's `vendor/package`).
154    Prefixed(SafePathSegment<'a>, SafePathSegment<'a>),
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_new_rejects_bare_dot_dot() {
163        assert!(SafePathSegment::new("..").is_err());
164    }
165
166    #[test]
167    fn test_new_rejects_bare_dot() {
168        assert!(SafePathSegment::new(".").is_err());
169    }
170
171    #[test]
172    fn test_new_accepts_normal_names() {
173        assert!(SafePathSegment::new("left-pad").is_ok());
174        assert!(SafePathSegment::new("../../search").is_ok());
175    }
176
177    #[test]
178    fn test_error_carries_the_rejected_segment() {
179        let err = SafePathSegment::new("..").unwrap_err();
180        assert_eq!(err.segment(), "..");
181    }
182
183    #[test]
184    fn test_as_str_roundtrips() {
185        let segment = SafePathSegment::new("express").unwrap();
186        assert_eq!(segment.as_str(), "express");
187    }
188
189    #[test]
190    fn test_checked_or_reject_accepts_normal_segment() {
191        assert!(SafePathSegment::checked_or_reject("express", "express", "ctx", "npm").is_ok());
192    }
193
194    #[test]
195    fn test_checked_or_reject_rejects_dot_segment() {
196        let err = SafePathSegment::checked_or_reject("..", "@a/..", "ctx", "npm").unwrap_err();
197        assert!(matches!(
198            err,
199            crate::error::DepsError::PackageNotFound { .. }
200        ));
201    }
202
203    /// The reported name must be the full, possibly compound identifier the caller passed in
204    /// — not just the offending sub-segment — so a rejection stays diagnosable.
205    #[test]
206    fn test_checked_or_reject_error_reports_full_name_not_bare_segment() {
207        let err = SafePathSegment::checked_or_reject("..", "@a/..", "ctx", "npm").unwrap_err();
208        let crate::error::DepsError::PackageNotFound { package, .. } = err else {
209            panic!("expected PackageNotFound, got {err:?}");
210        };
211        assert_eq!(package, "@a/..");
212    }
213}