deps_core/test_util.rs
1//! Test fixtures and helpers shared across ecosystem crates.
2//!
3//! Test fixtures throughout the workspace write absolute paths in Unix
4//! style (e.g. `/project/Cargo.toml`) for readability. `Uri::from_file_path`
5//! requires a platform-absolute path, and a Unix-style path is not
6//! recognized as absolute on Windows (no drive letter), so calling it
7//! directly with such a literal panics on Windows only. [`test_uri`]
8//! normalizes the path per host platform before constructing the [`Uri`].
9//!
10//! [`assert_dot_segment_gated_or_contained`]/[`assert_dot_segment_gated_or_contained_transformed`]
11//! guard the recurring dot-segment / unvalidated-URL-sink defect class (#337, #341, #349,
12//! #357, #361, #365, #371) against further recurrence.
13//!
14//! [`capture_tracing_output`]/[`capture_tracing_output_async`] let a test assert a
15//! `tracing` call actually fired (originally added standalone in `deps-swift` for #357,
16//! then duplicated per-crate for #380/#378's `warn_rejected_value` coverage before being
17//! consolidated here).
18
19use tower_lsp_server::ls_types::Uri;
20
21/// Builds a [`Uri`] from a Unix-style absolute test path.
22///
23/// On Windows, a synthetic `C:` drive is prefixed so the path is
24/// recognized as absolute; on other platforms the path is used as-is.
25///
26/// # Panics
27///
28/// Panics if the resulting path is not a valid file URI. This is a test
29/// helper: fixture paths are expected to always be well-formed.
30///
31/// # Examples
32///
33/// ```
34/// use deps_core::test_util::test_uri;
35///
36/// let uri = test_uri("/project/Cargo.toml");
37/// assert!(uri.path().as_str().ends_with("Cargo.toml"));
38/// ```
39#[must_use]
40pub fn test_uri(unix_path: &str) -> Uri {
41 #[cfg(windows)]
42 let owned;
43 #[cfg(windows)]
44 let path: &str = {
45 owned = format!("C:{unix_path}");
46 &owned
47 };
48 #[cfg(not(windows))]
49 let path: &str = unix_path;
50
51 Uri::from_file_path(path).expect("test_uri: fixture path must be a valid file URI")
52}
53
54/// Canonical adversarial identifier values for the recurring dot-segment /
55/// unvalidated-URL-sink defect class (#337, #341, #349, #357, #361).
56///
57/// A manifest-declared package name, scope, or coordinate segment spliced into a
58/// registry/API URL via `format!`/string interpolation without validation. A bare `.`/`..`
59/// survives naive percent-encoding unchanged (`.` is an RFC 3986 unreserved character) and
60/// is silently removed by a URL parser's dot-segment normalization once the string is
61/// assembled and parsed, letting the request escape the intended host or path prefix; the
62/// remaining entries cover a would-be traversal attempt, whitespace, and query/fragment
63/// injection.
64pub const ADVERSARIAL_URL_SEGMENTS: &[&str] = &[
65 ".",
66 "..",
67 "../../etc/passwd",
68 "a b",
69 "a?b=1",
70 "a#frag",
71 "%2e%2e",
72];
73
74/// Exercises one ecosystem's identifier-to-URL sink against every
75/// [`ADVERSARIAL_URL_SEGMENTS`] entry.
76///
77/// `resolve` should mirror the real request path: apply whatever validation gate
78/// (`is_dot_segment`, `is_safe_package_name`, `is_safe_maven_coordinate_segment`, ...) the
79/// production code runs before building the request, returning `None` when the gate would
80/// reject the identifier (the request is never built, so there is nothing to check), or
81/// `Some(url)` with the URL the identifier resolves to when it reaches the real
82/// fetch-URL-builder function directly.
83///
84/// For every input that reaches `Some(url)`, asserts `url` parses, stays under
85/// `expected_host`/`expected_path_prefix`, and that `segment` itself survives the round
86/// trip. That last check is the one that actually catches a missing/deleted gate: for an
87/// ecosystem whose identifier is the *first* path component (no fixed sub-path to nest
88/// under, e.g. `deps-cargo`'s sparse-index path or `deps-npm`'s bare
89/// `registry.npmjs.org/{name}`), `expected_path_prefix` can only ever be `"/"` — trivially
90/// satisfied by any path — so the prefix check alone is a tautology there. Deleting the
91/// real gate collapses `..`/`.` via dot-segment normalization, which removes it from the
92/// path entirely; the survival check catches that regardless of how trivial
93/// `expected_path_prefix` is, so passing `"/"` is fine as long as this check is also in
94/// effect.
95///
96/// The survival check itself takes one of two forms depending on `segment`:
97/// - For a bare `.`/`..` (the only values `url`'s dot-segment normalization treats
98/// specially): some path segment, once percent-decoded, must *start with* that value.
99/// A whole-path substring search would be too weak here — a coincidental `.` baked into
100/// a static suffix the sink always appends (e.g. `.json`) would satisfy `contains` even
101/// if the real `.`/`..` segment was silently removed, leaving no trace of it anywhere.
102/// `starts_with` (not exact equality) still accommodates a sink that glues the
103/// identifier directly onto a static suffix with no separator (e.g. `deps-bundler`'s
104/// `versions_url`, which decodes a `..` identifier to the segment `"..json"`).
105/// - For every other adversarial entry: the percent-decoded *whole path* must contain
106/// `segment` (or `transform(segment)`) as a substring — safe here since none of those
107/// entries collides with a static suffix the way a bare `.` does.
108///
109/// # Panics
110///
111/// Panics if a returned URL fails to parse, escapes `expected_host` or
112/// `expected_path_prefix`, if the (possibly transformed) segment is empty despite
113/// `resolve` returning `Some`, or if the segment does not survive per the rules above.
114///
115/// # Examples
116///
117/// ```
118/// use deps_core::test_util::assert_dot_segment_gated_or_contained;
119///
120/// fn build(name: &str) -> String {
121/// format!("https://example.com/api/{}", urlencoding::encode(name))
122/// }
123///
124/// fn resolve(name: &str) -> Option<String> {
125/// (name != "." && name != "..").then(|| build(name))
126/// }
127///
128/// assert_dot_segment_gated_or_contained(resolve, "example.com", "/api/");
129/// ```
130pub fn assert_dot_segment_gated_or_contained(
131 resolve: impl Fn(&str) -> Option<String>,
132 expected_host: &str,
133 expected_path_prefix: &str,
134) {
135 assert_dot_segment_gated_or_contained_transformed(
136 resolve,
137 str::to_string,
138 expected_host,
139 expected_path_prefix,
140 );
141}
142
143/// As [`assert_dot_segment_gated_or_contained`], but for a `resolve` whose production gate
144/// legitimately transforms the identifier before it reaches the URL builder.
145///
146/// E.g. PyPI's PEP 503 `name::normalize`, which collapses `.`/`_`/`-` runs, rather than
147/// passing the identifier through unchanged. `transform` computes what the identifier looks
148/// like once it reaches the sink, so the survival check compares the decoded path against
149/// that instead of the raw adversarial `segment` (which would otherwise never appear
150/// literally, producing a false-positive failure with no real bug behind it).
151///
152/// # Panics
153///
154/// Same conditions as [`assert_dot_segment_gated_or_contained`], with `transform(segment)`
155/// in place of `segment` for the survival check.
156pub fn assert_dot_segment_gated_or_contained_transformed(
157 resolve: impl Fn(&str) -> Option<String>,
158 transform: impl Fn(&str) -> String,
159 expected_host: &str,
160 expected_path_prefix: &str,
161) {
162 for segment in ADVERSARIAL_URL_SEGMENTS {
163 let Some(built) = resolve(segment) else {
164 continue;
165 };
166 let parsed = url::Url::parse(&built).unwrap_or_else(|e| {
167 panic!("adversarial segment {segment:?} produced an unparsable URL {built:?}: {e}")
168 });
169 assert_eq!(
170 parsed.host_str(),
171 Some(expected_host),
172 "adversarial segment {segment:?} escaped host: {built}"
173 );
174 assert!(
175 parsed.path().starts_with(expected_path_prefix),
176 "adversarial segment {segment:?} escaped path prefix {expected_path_prefix:?}: {built}"
177 );
178 let expected_fragment = transform(segment);
179 assert!(
180 !expected_fragment.is_empty(),
181 "adversarial segment {segment:?} transformed to an empty fragment but `resolve` \
182 still returned Some(url) — an empty identifier must be rejected (return `None`) \
183 before reaching the sink, since an empty survival check would trivially pass \
184 (\"\".contains(\"\") is always true) and hide a real gate deletion"
185 );
186 if expected_fragment == "." || expected_fragment == ".." {
187 // A whole-path substring search is too weak here: a coincidental `.` baked
188 // into a static suffix the sink always appends (e.g. `.json`/`.xml`) would
189 // satisfy `contains` even if the real gate is deleted and the actual `.`/`..`
190 // segment was silently removed by dot-segment normalization, leaving no trace
191 // of it anywhere. Require a per-segment match instead: some path segment, once
192 // decoded, must itself start with the dot-segment value — `starts_with` (not
193 // exact equality) accommodates a sink that glues the identifier directly onto a
194 // static suffix with no separator (e.g. deps-bundler's `versions_url`, which
195 // decodes a `..` identifier to the segment `"..json"`).
196 let matched = parsed.path_segments().is_some_and(|mut segs| {
197 segs.any(|seg| {
198 urlencoding::decode(seg)
199 .is_ok_and(|decoded| decoded.starts_with(expected_fragment.as_str()))
200 })
201 });
202 assert!(
203 matched,
204 "adversarial segment {segment:?} (expected to survive as {expected_fragment:?}) \
205 did not survive as its own path segment (silently dropped/collapsed by \
206 dot-segment normalization?): built url {built}"
207 );
208 } else {
209 let decoded_path = urlencoding::decode(parsed.path()).unwrap_or_else(|e| {
210 panic!(
211 "adversarial segment {segment:?}'s URL path failed to percent-decode: {built:?}: {e}"
212 )
213 });
214 assert!(
215 decoded_path.contains(&expected_fragment),
216 "adversarial segment {segment:?} (expected to survive as {expected_fragment:?}) \
217 did not survive intact in the decoded path (silently dropped/collapsed by \
218 dot-segment normalization?): decoded path {decoded_path:?}, built url {built}"
219 );
220 }
221 }
222}
223
224// Gated separately from the rest of this module (which also compiles under plain
225// `cfg(test)`, i.e. `cargo test -p deps-core` with no explicit features): `tracing-subscriber`
226// is an *optional* dependency enabled only by the `test-util` feature, so a build that hits
227// this module via bare `cfg(test)` alone would fail to resolve it without this narrower gate.
228#[cfg(feature = "test-util")]
229#[derive(Clone, Default)]
230struct CapturingWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
231
232#[cfg(feature = "test-util")]
233impl std::io::Write for CapturingWriter {
234 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
235 self.0.lock().unwrap().extend_from_slice(buf);
236 Ok(buf.len())
237 }
238
239 fn flush(&mut self) -> std::io::Result<()> {
240 Ok(())
241 }
242}
243
244#[cfg(feature = "test-util")]
245impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingWriter {
246 type Writer = Self;
247
248 fn make_writer(&'a self) -> Self::Writer {
249 self.clone()
250 }
251}
252
253#[cfg(feature = "test-util")]
254fn capturing_subscriber_at(
255 max_level: tracing::Level,
256) -> (CapturingWriter, impl tracing::Subscriber) {
257 let writer = CapturingWriter::default();
258 let subscriber = tracing_subscriber::fmt()
259 .with_writer(writer.clone())
260 .with_max_level(max_level)
261 .without_time()
262 .with_target(false)
263 .finish();
264 (writer, subscriber)
265}
266
267#[cfg(feature = "test-util")]
268fn capturing_subscriber() -> (CapturingWriter, impl tracing::Subscriber) {
269 // INFO (not WARN): some call sites emit `tracing::info!` (e.g. deps-swift's
270 // release-dates token-gate skip) that a WARN-only filter would silently drop,
271 // alongside every `warn_rejected_value`/other WARN-level emission this helper
272 // exists to assert on.
273 capturing_subscriber_at(tracing::Level::INFO)
274}
275
276/// Captures `tracing` output emitted synchronously during `f` into a `String`.
277///
278/// Lets a test assert a `tracing::warn!`/`info!` call actually fired — e.g.
279/// [`crate::lsp_helpers::warn_rejected_value`] — without a real logging sink or a
280/// network-dependent end-to-end path. Filters below `INFO`, so a `tracing::debug!`/`trace!`
281/// emission never appears here — use [`capture_tracing_output_at`] for those.
282///
283/// # Examples
284///
285/// ```
286/// use deps_core::test_util::capture_tracing_output;
287///
288/// let output = capture_tracing_output(|| tracing::warn!("something rejected"));
289/// assert!(output.contains("something rejected"));
290/// ```
291#[cfg(feature = "test-util")]
292#[must_use]
293pub fn capture_tracing_output(f: impl FnOnce()) -> String {
294 let (writer, subscriber) = capturing_subscriber();
295 tracing::subscriber::with_default(subscriber, f);
296 String::from_utf8(writer.0.lock().unwrap().clone()).expect("tracing output is valid utf8")
297}
298
299/// Like [`capture_tracing_output`], but capturing every level up to and including `max_level`
300/// (e.g. `tracing::Level::DEBUG`).
301///
302/// Needed to positively assert a `tracing::debug!` line fired, or that a `tracing::warn!`
303/// specifically (as opposed to any level) did not — [`capture_tracing_output`]'s fixed `INFO`
304/// filter makes both assertions vacuous, since a `debug!` call is invisible there regardless of
305/// whether the code under test emits it correctly.
306///
307/// # Examples
308///
309/// ```
310/// use deps_core::test_util::capture_tracing_output_at;
311///
312/// let output =
313/// capture_tracing_output_at(tracing::Level::DEBUG, || tracing::debug!("quiet detail"));
314/// assert!(output.contains("quiet detail"));
315/// ```
316#[cfg(feature = "test-util")]
317#[must_use]
318pub fn capture_tracing_output_at(max_level: tracing::Level, f: impl FnOnce()) -> String {
319 let (writer, subscriber) = capturing_subscriber_at(max_level);
320 tracing::subscriber::with_default(subscriber, f);
321 String::from_utf8(writer.0.lock().unwrap().clone()).expect("tracing output is valid utf8")
322}
323
324/// Async counterpart of [`capture_tracing_output`], for a `tracing` emission inside an
325/// `async fn`/`.await`ed future.
326///
327/// Relies on a `#[tokio::test]` current-thread runtime polling `fut` on the same thread
328/// that installed the subscriber as the thread-local default.
329///
330/// # Examples
331///
332/// ```
333/// use deps_core::test_util::capture_tracing_output_async;
334///
335/// # #[tokio::main]
336/// # async fn main() {
337/// let output = capture_tracing_output_async(async {
338/// tracing::warn!("something rejected");
339/// })
340/// .await;
341/// assert!(output.contains("something rejected"));
342/// # }
343/// ```
344#[cfg(feature = "test-util")]
345pub async fn capture_tracing_output_async(fut: impl std::future::Future<Output = ()>) -> String {
346 let (writer, subscriber) = capturing_subscriber();
347 let guard = tracing::subscriber::set_default(subscriber);
348 fut.await;
349 drop(guard);
350 String::from_utf8(writer.0.lock().unwrap().clone()).expect("tracing output is valid utf8")
351}