diff --git a/sigstore-cli/src/main/java/dev/sigstore/cli/Sign.java b/sigstore-cli/src/main/java/dev/sigstore/cli/Sign.java index c0c23047..f2a8c7fb 100644 --- a/sigstore-cli/src/main/java/dev/sigstore/cli/Sign.java +++ b/sigstore-cli/src/main/java/dev/sigstore/cli/Sign.java @@ -21,7 +21,7 @@ import dev.sigstore.oidc.client.TokenStringOidcClient; import dev.sigstore.tuf.RootProvider; import dev.sigstore.tuf.SigstoreTufClient; -import java.net.URL; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -88,7 +88,7 @@ public Integer call() throws Exception { SigstoreTufClient.builder() .usePublicGoodInstance() .tufMirror( - new URL(target.publicGoodWithTufUrlOverride), + URI.create(target.publicGoodWithTufUrlOverride), RootProvider.fromResource(SigstoreTufClient.PUBLIC_GOOD_ROOT_RESOURCE)); signerBuilder = KeylessSigner.builder() @@ -99,7 +99,7 @@ public Integer call() throws Exception { SigstoreTufClient.builder() .useStagingInstance() .tufMirror( - new URL(target.stagingWithTufUrlOverride), + URI.create(target.stagingWithTufUrlOverride), RootProvider.fromResource(SigstoreTufClient.STAGING_ROOT_RESOURCE)); signerBuilder = KeylessSigner.builder() diff --git a/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java b/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java index b103b86c..18f5a61b 100644 --- a/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java +++ b/sigstore-cli/src/main/java/dev/sigstore/cli/Verify.java @@ -26,7 +26,7 @@ import dev.sigstore.strings.StringMatcher; import dev.sigstore.tuf.RootProvider; import dev.sigstore.tuf.SigstoreTufClient; -import java.net.URL; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.concurrent.Callable; @@ -141,7 +141,7 @@ public Integer call() throws Exception { SigstoreTufClient.builder() .usePublicGoodInstance() .tufMirror( - new URL(target.publicGoodWithTufUrlOverride), + URI.create(target.publicGoodWithTufUrlOverride), RootProvider.fromResource(SigstoreTufClient.PUBLIC_GOOD_ROOT_RESOURCE)); verifier = KeylessVerifier.builder() @@ -152,7 +152,7 @@ public Integer call() throws Exception { SigstoreTufClient.builder() .useStagingInstance() .tufMirror( - new URL(target.stagingWithTufUrlOverride), + URI.create(target.stagingWithTufUrlOverride), RootProvider.fromResource(SigstoreTufClient.STAGING_ROOT_RESOURCE)); verifier = KeylessVerifier.builder() diff --git a/sigstore-java/src/main/java/dev/sigstore/http/URIFormat.java b/sigstore-java/src/main/java/dev/sigstore/http/URIFormat.java new file mode 100644 index 00000000..476de088 --- /dev/null +++ b/sigstore-java/src/main/java/dev/sigstore/http/URIFormat.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.http; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * A utility class for formatting URIs, providing predictable path appending. + * + *
This is preferable to {@link java.net.URI#resolve(String)} for simple path appending, as it
+ * avoids {@code resolve()}'s specific handling of base paths without trailing slashes and appended
+ * paths with leading slashes.
+ */
+public final class URIFormat {
+
+ private URIFormat() {}
+
+ /**
+ * Ensures the given URI's path has a trailing slash. This method correctly handles URIs with
+ * query parameters and fragments.
+ *
+ * @param input the URI to check.
+ * @return a new URI with a trailing slash, or the original URI if it already had one.
+ */
+ public static URI addTrailingSlash(URI input) {
+ String path = input.getPath();
+ if (path == null || path.isEmpty()) {
+ path = "";
+ } else if (path.endsWith("/")) {
+ return input;
+ }
+ try {
+ return new URI(
+ input.getScheme(),
+ input.getAuthority(),
+ path + "/",
+ input.getQuery(),
+ input.getFragment());
+ } catch (URISyntaxException e) {
+ // This should be unreachable with a valid input URI
+ throw new IllegalStateException("Could not append slash to invalid URI: " + input, e);
+ }
+ }
+
+ /**
+ * Appends a path segment to a base URI, ensuring exactly one slash separates them. This method
+ * will erase any query parameters or fragments
+ *
+ * @param base the base URI (e.g., "http://example.com/api?key=1").
+ * @param path the path segment to append (e.g., "users" or "/users").
+ * @return a new URI with the path appended (e.g., "http://example.com/api/users").
+ */
+ public static URI appendPath(URI base, String path) {
+ String relativePath = path.replaceAll("^/+", "");
+
+ // resolve has some goofy behavior unless we normalize everything before applying
+ return addTrailingSlash(base).resolve(relativePath);
+ }
+}
diff --git a/sigstore-java/src/main/java/dev/sigstore/tuf/HttpFetcher.java b/sigstore-java/src/main/java/dev/sigstore/tuf/HttpFetcher.java
index a4074ea9..bdd87303 100644
--- a/sigstore-java/src/main/java/dev/sigstore/tuf/HttpFetcher.java
+++ b/sigstore-java/src/main/java/dev/sigstore/tuf/HttpFetcher.java
@@ -20,29 +20,27 @@
import com.google.api.client.json.gson.GsonFactory;
import dev.sigstore.http.HttpClients;
import dev.sigstore.http.HttpParams;
+import dev.sigstore.http.URIFormat;
import java.io.IOException;
-import java.net.URL;
+import java.net.URI;
import java.util.Locale;
public class HttpFetcher implements Fetcher {
- private final URL mirror;
+ private final URI mirror;
private final HttpRequestFactory requestFactory;
- private HttpFetcher(URL mirror, HttpRequestFactory requestFactory) {
+ private HttpFetcher(URI mirror, HttpRequestFactory requestFactory) {
this.mirror = mirror;
this.requestFactory = requestFactory;
}
- public static HttpFetcher newFetcher(URL mirror) throws IOException {
+ public static HttpFetcher newFetcher(URI mirror) throws IOException {
var requestFactory =
HttpClients.newRequestFactory(
HttpParams.builder().build(),
GsonFactory.getDefaultInstance().createJsonObjectParser());
- if (mirror.toString().endsWith("/")) {
- return new HttpFetcher(mirror, requestFactory);
- }
- return new HttpFetcher(new URL(mirror.toExternalForm() + "/"), requestFactory);
+ return new HttpFetcher(URIFormat.addTrailingSlash(mirror), requestFactory);
}
@Override
@@ -53,7 +51,7 @@ public String getSource() {
@Override
public byte[] fetchResource(String filename, int maxLength)
throws IOException, FileExceedsMaxLengthException {
- GenericUrl fileUrl = new GenericUrl(mirror + filename);
+ GenericUrl fileUrl = new GenericUrl(URIFormat.appendPath(mirror, filename));
var req = requestFactory.buildGetRequest(fileUrl);
req.getHeaders().setAccept("application/json; api-version=2.0");
req.getHeaders().setContentType("application/json");
diff --git a/sigstore-java/src/main/java/dev/sigstore/tuf/SigstoreTufClient.java b/sigstore-java/src/main/java/dev/sigstore/tuf/SigstoreTufClient.java
index 64012ab6..dcf00a26 100644
--- a/sigstore-java/src/main/java/dev/sigstore/tuf/SigstoreTufClient.java
+++ b/sigstore-java/src/main/java/dev/sigstore/tuf/SigstoreTufClient.java
@@ -17,12 +17,12 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
+import dev.sigstore.http.URIFormat;
import dev.sigstore.trustroot.SigstoreConfigurationException;
import dev.sigstore.trustroot.SigstoreSigningConfig;
import dev.sigstore.trustroot.SigstoreTrustedRoot;
import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
+import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.InvalidKeyException;
@@ -65,7 +65,7 @@ public static class Builder {
Path tufCacheLocation =
Path.of(System.getProperty("user.home")).resolve(".sigstore-java").resolve("root");
- private URL remoteMirror;
+ private URI remoteMirror;
private RootProvider trustedRoot;
public Builder usePublicGoodInstance() {
@@ -73,13 +73,9 @@ public Builder usePublicGoodInstance() {
throw new IllegalStateException(
"Using public good after configuring remoteMirror and trustedRoot");
}
- try {
- tufMirror(
- new URL("https://tuf-repo-cdn.sigstore.dev/"),
- RootProvider.fromResource(PUBLIC_GOOD_ROOT_RESOURCE));
- } catch (MalformedURLException e) {
- throw new AssertionError(e);
- }
+ tufMirror(
+ URI.create("https://tuf-repo-cdn.sigstore.dev/"),
+ RootProvider.fromResource(PUBLIC_GOOD_ROOT_RESOURCE));
return this;
}
@@ -88,13 +84,9 @@ public Builder useStagingInstance() {
throw new IllegalStateException(
"Using staging after configuring remoteMirror and trustedRoot");
}
- try {
- tufMirror(
- new URL("https://tuf-repo-cdn.sigstage.dev"),
- RootProvider.fromResource(STAGING_ROOT_RESOURCE));
- } catch (MalformedURLException e) {
- throw new AssertionError(e);
- }
+ tufMirror(
+ URI.create("https://tuf-repo-cdn.sigstage.dev"),
+ RootProvider.fromResource(STAGING_ROOT_RESOURCE));
tufCacheLocation =
Path.of(System.getProperty("user.home"))
.resolve(".sigstore-java")
@@ -103,7 +95,7 @@ public Builder useStagingInstance() {
return this;
}
- public Builder tufMirror(URL mirror, RootProvider trustedRoot) {
+ public Builder tufMirror(URI mirror, RootProvider trustedRoot) {
this.remoteMirror = mirror;
this.trustedRoot = trustedRoot;
return this;
@@ -126,11 +118,7 @@ public SigstoreTufClient build() throws IOException {
if (!Files.isDirectory(tufCacheLocation)) {
Files.createDirectories(tufCacheLocation);
}
- var normalizedRemoteMirror =
- remoteMirror.toString().endsWith("/")
- ? remoteMirror
- : new URL(remoteMirror.toExternalForm() + "/");
- var remoteTargetsLocation = new URL(normalizedRemoteMirror.toExternalForm() + "targets");
+ var remoteTargetsLocation = URIFormat.appendPath(remoteMirror, "targets");
var filesystemTufStore = FileSystemTufStore.newFileSystemStore(tufCacheLocation);
var tufUpdater =
Updater.builder()
@@ -139,8 +127,7 @@ public SigstoreTufClient build() throws IOException {
TrustedMetaStore.newTrustedMetaStore(
PassthroughCacheMetaStore.newPassthroughMetaCache(filesystemTufStore)))
.setTargetStore(filesystemTufStore)
- .setMetaFetcher(
- MetaFetcher.newFetcher(HttpFetcher.newFetcher(normalizedRemoteMirror)))
+ .setMetaFetcher(MetaFetcher.newFetcher(HttpFetcher.newFetcher(remoteMirror)))
.setTargetFetcher(HttpFetcher.newFetcher(remoteTargetsLocation))
.build();
return new SigstoreTufClient(tufUpdater, cacheValidity);
diff --git a/sigstore-java/src/test/java/dev/sigstore/http/URIFormatTest.java b/sigstore-java/src/test/java/dev/sigstore/http/URIFormatTest.java
new file mode 100644
index 00000000..ade2b928
--- /dev/null
+++ b/sigstore-java/src/test/java/dev/sigstore/http/URIFormatTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2025 The Sigstore Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.sigstore.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.net.URI;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class URIFormatTest {
+
+ // Data provider for addTrailingSlash tests
+ static Stream