1use crate::checksum::ChecksumAlgo;
16use crate::coordinate::Coordinate;
17use crate::error::MavenError;
18use crate::snapshot::is_snapshot_version;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LayoutPath {
23 pub coordinate: Coordinate,
25 pub class: PathClass,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PathClass {
32 Artifact,
34 Checksum(ChecksumAlgo),
36 Metadata {
40 version_level: bool,
43 checksum: Option<ChecksumAlgo>,
45 },
46}
47
48pub 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 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 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 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 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
159enum MetadataKind {
161 Raw,
163 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
184fn split_filename(
187 artifact_id: &str,
188 version: &str,
189 filename: &str,
190) -> Result<(Option<String>, String), MavenError> {
191 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 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 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#[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 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 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 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 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 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 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 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 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 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}