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}