Skip to main content

ferro_maven_layout/
layout.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Maven layout path parser.
3//!
4//! Translates between:
5//!
6//! - an incoming URL path such as
7//!   `com/example/foo/1.0/foo-1.0.jar`, and
8//! - a structured [`Coordinate`] plus a [`PathClass`] marker that
9//!   distinguishes the artifact itself, a checksum sidecar, or a
10//!   `maven-metadata.xml` document.
11//!
12//! Spec: Maven Repository Layout —
13//! <https://maven.apache.org/repository/layout.html>.
14
15use crate::checksum::ChecksumAlgo;
16use crate::coordinate::Coordinate;
17use crate::error::MavenError;
18use crate::snapshot::is_snapshot_version;
19
20/// Result of parsing a Maven repository path.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LayoutPath {
23    /// The coordinate identified by the path.
24    pub coordinate: Coordinate,
25    /// What kind of resource the path addresses.
26    pub class: PathClass,
27}
28
29/// Classification of a Maven layout path.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PathClass {
32    /// A main artifact (jar, pom, war, tar.gz, ...).
33    Artifact,
34    /// A checksum sidecar alongside an artifact.
35    Checksum(ChecksumAlgo),
36    /// A `maven-metadata.xml` or `maven-metadata.xml.sha1` etc. under
37    /// the artifactId directory (groupLevel = false) or an optional
38    /// version-level metadata for SNAPSHOT builds.
39    Metadata {
40        /// Whether the metadata path is under a `version/` directory
41        /// (true = SNAPSHOT timestamp metadata, false = artifact index).
42        version_level: bool,
43        /// Optional checksum algorithm for metadata sidecars.
44        checksum: Option<ChecksumAlgo>,
45    },
46}
47
48/// Parse a layout path into a structured form.
49///
50/// Accepts paths with or without a leading slash. The path must contain
51/// at least three segments: `{groupPath}/{artifactId}/{version}/{filename}`.
52///
53/// # Errors
54///
55/// Returns [`MavenError::InvalidPath`] if the path has fewer than four
56/// segments or the filename does not match the expected
57/// `{artifactId}-{version}[-{classifier}].{extension}` shape. Paths that
58/// point at a `maven-metadata.xml` (with or without checksum sidecar
59/// suffix) are classified with [`PathClass::Metadata`] regardless of
60/// filename shape.
61pub fn parse_layout_path(path: &str) -> Result<LayoutPath, MavenError> {
62    let trimmed = path.trim_start_matches('/');
63    let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
64
65    if segments.len() < 3 {
66        return Err(MavenError::InvalidPath(format!(
67            "path `{path}` has fewer than 3 segments"
68        )));
69    }
70
71    let filename = segments
72        .last()
73        .copied()
74        .ok_or_else(|| MavenError::InvalidPath("path has no filename".into()))?;
75
76    // Detect metadata documents: the filename begins with
77    // `maven-metadata.xml` and may optionally be followed by a checksum
78    // extension.
79    if let Some(kind) = maven_metadata_suffix(filename) {
80        let checksum = match kind {
81            MetadataKind::Raw => None,
82            MetadataKind::Sidecar(a) => Some(a),
83        };
84        return classify_metadata(&segments, checksum);
85    }
86
87    // Otherwise the path is {groupPath..}/{artifactId}/{version}/{filename}.
88    // Require at least 4 segments (group has >=1 segment).
89    if segments.len() < 4 {
90        return Err(MavenError::InvalidPath(format!(
91            "artifact path `{path}` has fewer than 4 segments"
92        )));
93    }
94
95    let version = segments[segments.len() - 2];
96    let artifact_id = segments[segments.len() - 3];
97    let group_segments = &segments[..segments.len() - 3];
98    let group_id = group_segments.join(".");
99
100    let (stripped, checksum) = strip_checksum_suffix(filename);
101    let (classifier, extension) = split_filename(artifact_id, version, stripped)?;
102
103    let coordinate = Coordinate::new(group_id, artifact_id, version, classifier, extension)
104        .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
105
106    let class = checksum.map_or(PathClass::Artifact, PathClass::Checksum);
107
108    Ok(LayoutPath { coordinate, class })
109}
110
111fn classify_metadata(
112    segments: &[&str],
113    checksum: Option<ChecksumAlgo>,
114) -> Result<LayoutPath, MavenError> {
115    // Metadata can live at either
116    //   groupPath/artifactId/maven-metadata.xml   (>=2 segments before file)
117    //   groupPath/artifactId/version/maven-metadata.xml (>=3 segments before file)
118    // We heuristically classify by whether the penultimate segment looks
119    // like a version (contains a digit).
120    if segments.len() < 3 {
121        return Err(MavenError::InvalidPath(
122            "metadata path must have at least 2 path components before the filename".into(),
123        ));
124    }
125    let before_file = &segments[..segments.len() - 1];
126    let last = before_file.last().copied().unwrap_or_default();
127    let version_level = last.chars().any(|c| c.is_ascii_digit());
128
129    if version_level && before_file.len() >= 3 {
130        let version = last.to_string();
131        let artifact_id = before_file[before_file.len() - 2].to_string();
132        let group_id = before_file[..before_file.len() - 2].join(".");
133        let coordinate = Coordinate::new(group_id, artifact_id, version, None::<String>, "pom")
134            .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
135        Ok(LayoutPath {
136            coordinate,
137            class: PathClass::Metadata {
138                version_level: true,
139                checksum,
140            },
141        })
142    } else {
143        let artifact_id = last.to_string();
144        let group_id = before_file[..before_file.len() - 1].join(".");
145        // Use an "index" placeholder version for the coordinate since
146        // artifactId-level metadata is not tied to a specific version.
147        let coordinate = Coordinate::new(group_id, artifact_id, "index", None::<String>, "pom")
148            .map_err(|e| MavenError::InvalidPath(format!("{e}")))?;
149        Ok(LayoutPath {
150            coordinate,
151            class: PathClass::Metadata {
152                version_level: false,
153                checksum,
154            },
155        })
156    }
157}
158
159/// Marker for a `maven-metadata.xml` classification decision.
160enum MetadataKind {
161    /// Raw metadata document.
162    Raw,
163    /// Checksum sidecar alongside the metadata.
164    Sidecar(ChecksumAlgo),
165}
166
167fn maven_metadata_suffix(name: &str) -> Option<MetadataKind> {
168    if name == "maven-metadata.xml" {
169        return Some(MetadataKind::Raw);
170    }
171    let rest = name.strip_prefix("maven-metadata.xml.")?;
172    ChecksumAlgo::from_extension(rest).map(MetadataKind::Sidecar)
173}
174
175fn strip_checksum_suffix(name: &str) -> (&str, Option<ChecksumAlgo>) {
176    if let Some((stem, ext)) = name.rsplit_once('.')
177        && let Some(algo) = ChecksumAlgo::from_extension(ext)
178    {
179        return (stem, Some(algo));
180    }
181    (name, None)
182}
183
184/// Split `{artifactId}-{version}[-{classifier}].{extension}` into its
185/// classifier and extension.
186fn split_filename(
187    artifact_id: &str,
188    version: &str,
189    filename: &str,
190) -> Result<(Option<String>, String), MavenError> {
191    // Strip the prefix `{artifactId}-{version}` first.
192    let prefix = format!("{artifact_id}-{version}");
193    let rest = filename.strip_prefix(&prefix).ok_or_else(|| {
194        MavenError::InvalidPath(format!(
195            "filename `{filename}` does not start with `{prefix}`"
196        ))
197    })?;
198
199    if let Some(tail) = rest.strip_prefix('-') {
200        // Classifier present: `-{classifier}.{extension}`.
201        // Extension is everything after the last dot; support
202        // compound extensions (`tar.gz`, `tar.bz2`) by matching known
203        // patterns.
204        let (classifier, extension) = split_classifier_and_extension(tail).ok_or_else(|| {
205            MavenError::InvalidPath(format!(
206                "filename tail `{tail}` must be `classifier.extension`"
207            ))
208        })?;
209        Ok((Some(classifier), extension))
210    } else if let Some(tail) = rest.strip_prefix('.') {
211        Ok((None, tail.to_string()))
212    } else {
213        Err(MavenError::InvalidPath(format!(
214            "filename `{filename}` has no extension separator"
215        )))
216    }
217}
218
219fn split_classifier_and_extension(tail: &str) -> Option<(String, String)> {
220    // Recognise compound extensions first.
221    const COMPOUND: &[&str] = &["tar.gz", "tar.bz2", "tar.xz", "tar.zst"];
222    for compound in COMPOUND {
223        let dotted = format!(".{compound}");
224        if let Some(classifier) = tail.strip_suffix(&dotted)
225            && !classifier.is_empty()
226        {
227            return Some((classifier.to_string(), (*compound).to_string()));
228        }
229    }
230    let dot = tail.rfind('.')?;
231    let classifier = &tail[..dot];
232    let extension = &tail[dot + 1..];
233    if classifier.is_empty() || extension.is_empty() {
234        return None;
235    }
236    Some((classifier.to_string(), extension.to_string()))
237}
238
239/// Convenience: check whether a parsed [`LayoutPath`] sits on a SNAPSHOT
240/// version.
241#[must_use]
242pub fn layout_is_snapshot(path: &LayoutPath) -> bool {
243    is_snapshot_version(&path.coordinate.version)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::{PathClass, layout_is_snapshot, parse_layout_path};
249    use crate::checksum::ChecksumAlgo;
250
251    #[test]
252    fn three_segment_metadata_path_is_accepted() {
253        // Exactly three segments: `{group}/{artifactId}/maven-metadata.xml`.
254        // Distinguishes `segments.len() < 3` from `<= 3` at the top-level
255        // guard (line 65) and inside `classify_metadata` (line 120):
256        // `< 3` admits this path, `<= 3` / `== 3` would reject it.
257        let p = parse_layout_path("com/example/maven-metadata.xml").expect("ok");
258        assert_eq!(p.coordinate.group_id, "com");
259        assert_eq!(p.coordinate.artifact_id, "example");
260        assert!(matches!(
261            p.class,
262            PathClass::Metadata {
263                version_level: false,
264                checksum: None
265            }
266        ));
267    }
268
269    #[test]
270    fn four_segment_artifact_path_is_accepted() {
271        // Minimal artifact path with exactly four segments. Catches the
272        // `segments.len() < 4` boundary (line 89): replacing `<` with
273        // `<=` would reject this valid four-segment path.
274        let p = parse_layout_path("g/foo/1.0/foo-1.0.jar").expect("ok");
275        assert_eq!(p.coordinate.group_id, "g");
276        assert_eq!(p.coordinate.artifact_id, "foo");
277        assert_eq!(p.coordinate.version, "1.0");
278        assert_eq!(p.coordinate.extension, "jar");
279        assert_eq!(p.class, PathClass::Artifact);
280    }
281
282    #[test]
283    fn three_segment_artifact_path_is_rejected() {
284        // Exactly three non-metadata segments must fail the line-89
285        // `< 4` guard. With `<` the path is rejected; with `==` (3 == 4
286        // is false) it would slip through and fail later with a
287        // different message, so assert the exact "fewer than 4" wording.
288        let err = parse_layout_path("foo/1.0/foo-1.0.jar").expect_err("reject");
289        assert!(
290            err.to_string().contains("fewer than 4 segments"),
291            "unexpected: {err}"
292        );
293    }
294
295    #[test]
296    fn deep_version_level_metadata_resolves_group_and_artifact() {
297        // before_file has six entries, so `len - 2 = 4` differs from
298        // `len / 2 = 3`. Asserting the exact artifactId / groupId catches
299        // the `- 2` → `/ 2` subtraction mutants (lines 131 & 132): the
300        // mutant would pick `before_file[3]` ("d") as the artifactId and
301        // `a.b.c` as the groupId.
302        let p =
303            parse_layout_path("a/b/c/d/foo/1.0-SNAPSHOT/maven-metadata.xml").expect("ok");
304        assert_eq!(p.coordinate.artifact_id, "foo");
305        assert_eq!(p.coordinate.group_id, "a.b.c.d");
306        assert_eq!(p.coordinate.version, "1.0-SNAPSHOT");
307        assert!(matches!(
308            p.class,
309            PathClass::Metadata {
310                version_level: true,
311                ..
312            }
313        ));
314    }
315
316    #[test]
317    fn empty_classifier_with_extension_is_rejected() {
318        // Filename `foo-1.0-.jar` yields tail `.jar`, i.e. an empty
319        // classifier with a non-empty extension. The `is_empty() ||
320        // is_empty()` guard in `split_classifier_and_extension` must
321        // reject it; an `&&` mutant would accept an empty classifier.
322        let err = parse_layout_path("g/foo/1.0/foo-1.0-.jar").expect_err("reject");
323        assert!(
324            err.to_string().contains("classifier.extension"),
325            "unexpected: {err}"
326        );
327    }
328
329    #[test]
330    fn layout_is_snapshot_reflects_version() {
331        // Pin both truth values so the function body cannot be replaced
332        // by a constant `true` or `false`.
333        let snap = parse_layout_path(
334            "com/example/foo/1.0-SNAPSHOT/foo-1.0-SNAPSHOT.jar",
335        )
336        .expect("ok");
337        assert!(layout_is_snapshot(&snap));
338
339        let release = parse_layout_path("com/example/foo/1.0/foo-1.0.jar").expect("ok");
340        assert!(!layout_is_snapshot(&release));
341    }
342
343    #[test]
344    fn parses_simple_jar_path() {
345        let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar").expect("ok");
346        assert_eq!(p.coordinate.group_id, "com.example");
347        assert_eq!(p.coordinate.artifact_id, "foo");
348        assert_eq!(p.coordinate.version, "1.0");
349        assert_eq!(p.coordinate.extension, "jar");
350        assert_eq!(p.coordinate.classifier, None);
351        assert_eq!(p.class, PathClass::Artifact);
352    }
353
354    #[test]
355    fn parses_classifier_jar() {
356        let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
357        assert_eq!(p.coordinate.classifier.as_deref(), Some("sources"));
358        assert_eq!(p.coordinate.extension, "jar");
359    }
360
361    #[test]
362    fn parses_sha1_sidecar() {
363        let p = parse_layout_path("com/example/foo/1.0/foo-1.0.jar.sha1").expect("ok");
364        assert_eq!(p.coordinate.extension, "jar");
365        assert_eq!(p.class, PathClass::Checksum(ChecksumAlgo::Sha1));
366    }
367
368    #[test]
369    fn parses_pom() {
370        let p = parse_layout_path("com/example/foo/1.0/foo-1.0.pom").expect("ok");
371        assert_eq!(p.coordinate.extension, "pom");
372    }
373
374    #[test]
375    fn parses_metadata_under_artifact_id() {
376        let p = parse_layout_path("com/example/foo/maven-metadata.xml").expect("ok");
377        assert!(matches!(
378            p.class,
379            PathClass::Metadata {
380                version_level: false,
381                checksum: None
382            }
383        ));
384    }
385
386    #[test]
387    fn parses_metadata_under_version() {
388        let p = parse_layout_path("com/example/foo/1.0-SNAPSHOT/maven-metadata.xml").expect("ok");
389        assert!(matches!(
390            p.class,
391            PathClass::Metadata {
392                version_level: true,
393                ..
394            }
395        ));
396    }
397
398    #[test]
399    fn rejects_too_short_path() {
400        let err = parse_layout_path("foo/1.0").expect_err("reject");
401        assert!(err.to_string().contains("fewer than 3 segments"));
402    }
403
404    #[test]
405    fn compound_tar_gz_extension_preserved() {
406        let p = parse_layout_path("com/example/foo/1.0/foo-1.0-dist.tar.gz").expect("ok");
407        assert_eq!(p.coordinate.extension, "tar.gz");
408        assert_eq!(p.coordinate.classifier.as_deref(), Some("dist"));
409    }
410
411    #[test]
412    fn round_trip_path_to_coordinate_and_back() {
413        let p = parse_layout_path("com/example/foo/1.0/foo-1.0-sources.jar").expect("ok");
414        assert_eq!(
415            p.coordinate.repository_path(),
416            "com/example/foo/1.0/foo-1.0-sources.jar"
417        );
418    }
419
420    #[test]
421    fn fuzz_crash_path_traversal_input_is_rejected() {
422        // Regression for the `parse_layout_path` fuzz crash
423        // (crash-b8d04a21..): bytes `\x17/../\x00//..-\x00.t`. The path
424        // contained a `..` version/artifact component plus NUL bytes that
425        // previously slipped through `Coordinate::new` validation and let
426        // `repository_path` re-render a `..` traversal segment — a
427        // path-traversal security bug for the Maven repository layout.
428        let crash = "\u{17}/../\u{0}//..-\u{0}.t";
429        parse_layout_path(crash).expect_err("traversal crash input must be rejected");
430    }
431
432    #[test]
433    fn dotdot_component_path_is_rejected() {
434        // A clean `..` groupId / artifactId / version component must be
435        // rejected so a re-rendered path cannot escape the repo root.
436        for path in [
437            "../foo/1.0/foo-1.0.jar",
438            "g/../1.0/foo-1.0.jar",
439            "g/foo/../foo-1.0.jar",
440        ] {
441            parse_layout_path(path).expect_err("`..` component path must be rejected");
442        }
443    }
444
445    #[test]
446    fn accepted_paths_never_render_a_traversal_segment() {
447        // The invariant the fuzzer enforces: for every accepted artifact
448        // or checksum path, the re-rendered repository path contains no
449        // `..` (or `.`) traversal segment.
450        for path in [
451            "com/example/foo/1.0/foo-1.0.jar",
452            "com/example/foo/1.0/foo-1.0-sources.jar",
453            "com/example/foo/1.0/foo-1.0.jar.sha1",
454            "com/example/foo/1.0-SNAPSHOT/foo-1.0-SNAPSHOT-dist.tar.gz",
455        ] {
456            let p = parse_layout_path(path).expect("ok");
457            if matches!(p.class, PathClass::Artifact | PathClass::Checksum(_)) {
458                for seg in p.coordinate.repository_path().split('/') {
459                    assert_ne!(seg, "..", "traversal segment in {path}");
460                    assert_ne!(seg, ".", "current-dir segment in {path}");
461                }
462            }
463        }
464    }
465}