Skip to content

Commit 748e962

Browse files
[Java.Interop.Tools.Maven] Validate Artifact coordinates (#1479)
Context: https://github.com/dotnet/android (the `<AndroidMavenLibrary>` MSBuild item relies on `Artifact`/`Artifact.TryParse` to validate Maven coordinates supplied by users, but today the constructor blindly assigns its fields and `TryParse` only checks for three colon-separated parts. Empty strings, whitespace, and characters that are illegal per the Maven coordinate spec all slip through. Add structural validation to `Artifact`: * `groupId` and `artifactId` must match `[A-Za-z0-9_\-.]+` per https://maven.apache.org/pom.html#Maven_Coordinates. * `version` rejects whitespace, `:`, and path separators (`/`, `\`). Maven versions are otherwise permissive, so no further parsing. * The constructor throws `ArgumentNullException` / `ArgumentException` with the offending parameter and value. * `TryParse` returns `false` for any invalid input (and requires all three parts to be non-empty) without throwing. * `Parse` continues to throw `ArgumentException` with the existing message format. The constructor still allows an empty `version` so that existing internal callers - `Dependency.ToArtifact ()` and `Project.TryGetParentPomArtifact ()` - can keep producing partial coordinates when a POM omits `<version>` and inherits it from a parent. `TryParse`/`Parse` (the user-input path) still require a non-empty version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent efd4e72 commit 748e962

2 files changed

Lines changed: 180 additions & 2 deletions

File tree

external/Java.Interop/src/Java.Interop.Tools.Maven/Models/Artifact.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ public class Artifact
1818

1919
public Artifact (string groupId, string artifactId, string version)
2020
{
21+
if (groupId is null)
22+
throw new ArgumentNullException (nameof (groupId));
23+
if (artifactId is null)
24+
throw new ArgumentNullException (nameof (artifactId));
25+
if (version is null)
26+
throw new ArgumentNullException (nameof (version));
27+
if (!IsValidCoordinate (groupId))
28+
throw new ArgumentException ($"Invalid Maven groupId: '{groupId}'", nameof (groupId));
29+
if (!IsValidCoordinate (artifactId))
30+
throw new ArgumentException ($"Invalid Maven artifactId: '{artifactId}'", nameof (artifactId));
31+
// Allow empty version (callers may construct a partial coordinate when
32+
// the version is to be inherited from a parent POM), but reject
33+
// whitespace-only or otherwise malformed non-empty values.
34+
if (version.Length > 0 && !IsValidVersion (version))
35+
throw new ArgumentException ($"Invalid Maven version: '{version}'", nameof (version));
36+
2137
Id = artifactId;
2238
GroupId = groupId;
2339
Version = version;
@@ -31,19 +47,56 @@ public static Artifact Parse (string value)
3147
throw new ArgumentException ($"Invalid artifact format: {value}");
3248
}
3349

34-
public static bool TryParse (string value, [NotNullWhen (true)]out Artifact? artifact)
50+
public static bool TryParse (string? value, [NotNullWhen (true)]out Artifact? artifact)
3551
{
3652
artifact = null;
3753

38-
var parts = value.Split (':');
54+
if (value is null)
55+
return false;
56+
57+
var parts = value.Split ([':'], 4);
3958

4059
if (parts.Length != 3)
4160
return false;
4261

62+
// Parsed coordinates must have all three parts fully populated.
63+
if (!IsValidCoordinate (parts [0]) || !IsValidCoordinate (parts [1]) || !IsValidVersion (parts [2]))
64+
return false;
65+
4366
artifact = new Artifact (parts [0], parts [1], parts [2]);
4467

4568
return true;
4669
}
4770

71+
// Per https://maven.apache.org/pom.html#Maven_Coordinates groupId/artifactId
72+
// must match [A-Za-z0-9_\-.]+
73+
static bool IsValidCoordinate (string value)
74+
{
75+
if (string.IsNullOrWhiteSpace (value))
76+
return false;
77+
foreach (var c in value) {
78+
if (!((c >= 'A' && c <= 'Z') ||
79+
(c >= 'a' && c <= 'z') ||
80+
(c >= '0' && c <= '9') ||
81+
c == '_' || c == '-' || c == '.'))
82+
return false;
83+
}
84+
return true;
85+
}
86+
87+
// Maven versions are permissive; reject only obviously broken values
88+
// (empty/whitespace, embedded whitespace, path separators, or ':' which
89+
// would break parsing).
90+
static bool IsValidVersion (string value)
91+
{
92+
if (string.IsNullOrWhiteSpace (value))
93+
return false;
94+
foreach (var c in value) {
95+
if (c == ':' || c == '/' || c == '\\' || char.IsWhiteSpace (c))
96+
return false;
97+
}
98+
return true;
99+
}
100+
48101
public override string ToString () => VersionedArtifactString;
49102
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using System;
2+
using Java.Interop.Tools.Maven.Models;
3+
4+
namespace Java.Interop.Tools.Maven_Tests;
5+
6+
public class ArtifactTests
7+
{
8+
[TestCase ("com.google.guava:guava:31.1-jre", "com.google.guava", "guava", "31.1-jre")]
9+
[TestCase ("androidx.core:core:1.9.0", "androidx.core", "core", "1.9.0")]
10+
[TestCase ("a:b:1", "a", "b", "1")]
11+
[TestCase ("group_1-x.y:artifact-id_2:1.0.0-SNAPSHOT", "group_1-x.y", "artifact-id_2", "1.0.0-SNAPSHOT")]
12+
public void TryParse_Valid (string value, string groupId, string artifactId, string version)
13+
{
14+
Assert.IsTrue (Artifact.TryParse (value, out var artifact));
15+
Assert.AreEqual (groupId, artifact!.GroupId);
16+
Assert.AreEqual (artifactId, artifact.Id);
17+
Assert.AreEqual (version, artifact.Version);
18+
Assert.AreEqual ($"{groupId}:{artifactId}", artifact.ArtifactString);
19+
Assert.AreEqual (value, artifact.VersionedArtifactString);
20+
Assert.AreEqual (value, artifact.ToString ());
21+
}
22+
23+
[TestCase ("com.google.guava:guava:31.1-jre", "com.google.guava", "guava", "31.1-jre")]
24+
public void Parse_Valid (string value, string groupId, string artifactId, string version)
25+
{
26+
var artifact = Artifact.Parse (value);
27+
Assert.AreEqual (groupId, artifact.GroupId);
28+
Assert.AreEqual (artifactId, artifact.Id);
29+
Assert.AreEqual (version, artifact.Version);
30+
}
31+
32+
[TestCase ("")]
33+
[TestCase ("foo")]
34+
[TestCase ("foo:bar")]
35+
[TestCase ("a:b:c:d")]
36+
[TestCase ("::")]
37+
[TestCase ("a::1")]
38+
[TestCase (":b:1")]
39+
[TestCase ("a:b:")]
40+
[TestCase (" :b:1")]
41+
[TestCase ("a b:c:1")]
42+
[TestCase ("a:b c:1")]
43+
[TestCase ("a:b:1 0")]
44+
[TestCase ("a/b:c:1")]
45+
[TestCase ("a:b@c:1")]
46+
[TestCase ("a:b!:1")]
47+
[TestCase ("a:b:c:d:e:f:g")]
48+
[TestCase ("../a:b:1")]
49+
[TestCase ("a:../b:1")]
50+
[TestCase ("a:b:../1")]
51+
[TestCase ("a:b:1.0/../")]
52+
[TestCase ("a/../b:c:1")]
53+
[TestCase ("..\\a:b:1")]
54+
[TestCase ("a:b:..\\1.0")]
55+
public void TryParse_Invalid (string value)
56+
{
57+
Assert.IsFalse (Artifact.TryParse (value, out var artifact));
58+
Assert.IsNull (artifact);
59+
}
60+
61+
[Test]
62+
public void TryParse_Null ()
63+
{
64+
Assert.IsFalse (Artifact.TryParse (null!, out var artifact));
65+
Assert.IsNull (artifact);
66+
}
67+
68+
[TestCase ("")]
69+
[TestCase ("foo")]
70+
[TestCase ("a:b c:1")]
71+
public void Parse_Invalid_Throws (string value)
72+
{
73+
Assert.Throws<ArgumentException> (() => Artifact.Parse (value));
74+
}
75+
76+
[Test]
77+
public void Ctor_Valid ()
78+
{
79+
var artifact = new Artifact ("com.example", "lib", "1.0.0");
80+
Assert.AreEqual ("com.example", artifact.GroupId);
81+
Assert.AreEqual ("lib", artifact.Id);
82+
Assert.AreEqual ("1.0.0", artifact.Version);
83+
}
84+
85+
[TestCase (null, "lib", "1.0")]
86+
[TestCase ("com.example", null, "1.0")]
87+
[TestCase ("com.example", "lib", null)]
88+
public void Ctor_Null_Throws (string? groupId, string? artifactId, string? version)
89+
{
90+
Assert.Throws<ArgumentNullException> (() => new Artifact (groupId!, artifactId!, version!));
91+
}
92+
93+
[TestCase ("", "lib", "1.0")]
94+
[TestCase (" ", "lib", "1.0")]
95+
[TestCase ("a b", "lib", "1.0")]
96+
[TestCase ("a/b", "lib", "1.0")]
97+
[TestCase ("a@b", "lib", "1.0")]
98+
[TestCase ("com.example", "", "1.0")]
99+
[TestCase ("com.example", " ", "1.0")]
100+
[TestCase ("com.example", "li b", "1.0")]
101+
[TestCase ("com.example", "lib!", "1.0")]
102+
[TestCase ("com.example", "lib", " ")]
103+
[TestCase ("com.example", "lib", "1 0")]
104+
[TestCase ("com.example", "lib", "1:0")]
105+
[TestCase ("../com.example", "lib", "1.0")]
106+
[TestCase ("com.example", "../lib", "1.0")]
107+
[TestCase ("com.example", "lib", "../1.0")]
108+
[TestCase ("com.example", "lib", "1.0/../")]
109+
[TestCase ("com.example", "lib", "..\\1.0")]
110+
[TestCase ("com/example", "lib", "1.0")]
111+
public void Ctor_Invalid_Throws (string groupId, string artifactId, string version)
112+
{
113+
Assert.Throws<ArgumentException> (() => new Artifact (groupId, artifactId, version));
114+
}
115+
116+
[Test]
117+
public void Ctor_AllowsEmptyVersion ()
118+
{
119+
// Empty version is permitted in the constructor to support partial
120+
// coordinates produced from POM XML where <version> is omitted and
121+
// inherited from a parent POM.
122+
var artifact = new Artifact ("com.example", "lib", "");
123+
Assert.AreEqual ("", artifact.Version);
124+
}
125+
}

0 commit comments

Comments
 (0)