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 addTrailingSlashCases() { + return Stream.of( + Arguments.of( + URI.create("https://example.com/path/"), URI.create("https://example.com/path/")), + Arguments.of( + URI.create("https://example.com/path"), URI.create("https://example.com/path/")), + Arguments.of(URI.create("https://example.com"), URI.create("https://example.com/")), + Arguments.of( + URI.create("https://example.com/path?query=1"), + URI.create("https://example.com/path/?query=1")), + Arguments.of( + URI.create("https://example.com/path#fragment"), + URI.create("https://example.com/path/#fragment")), + Arguments.of( + URI.create("https://example.com/path?query=1#fragment"), + URI.create("https://example.com/path/?query=1#fragment"))); + } + + // Data provider for appendPath tests + static Stream appendPathCases() { + return Stream.of( + Arguments.of( + URI.create("https://example.com/api/"), + "users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com/api"), + "users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com/api/"), + "/users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com/api"), + "///users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com/api?key=123"), + "users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com/api?key=123#section"), + "users", + URI.create("https://example.com/api/users")), + Arguments.of( + URI.create("https://example.com"), + "/users/get", + URI.create("https://example.com/users/get"))); + } + + @ParameterizedTest + @MethodSource("addTrailingSlashCases") + void addTrailingSlash(URI input, URI expected) { + URI result = URIFormat.addTrailingSlash(input); + assertEquals(expected, result); + } + + @ParameterizedTest + @MethodSource("appendPathCases") + void appendPath(URI base, String path, URI expected) { + URI result = URIFormat.appendPath(base, path); + assertEquals(expected, result); + } +} diff --git a/sigstore-java/src/test/java/dev/sigstore/tuf/HttpFetcherTest.java b/sigstore-java/src/test/java/dev/sigstore/tuf/HttpFetcherTest.java index fc235f14..66d98d46 100644 --- a/sigstore-java/src/test/java/dev/sigstore/tuf/HttpFetcherTest.java +++ b/sigstore-java/src/test/java/dev/sigstore/tuf/HttpFetcherTest.java @@ -15,7 +15,8 @@ */ package dev.sigstore.tuf; -import java.net.URL; +import java.io.IOException; +import java.net.URI; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -24,8 +25,8 @@ class HttpFetcherTest { @ParameterizedTest @CsvSource({"http://example.com", "http://example.com/"}) - public void newFetcher_urlNoTrailingSlash(String url) throws Exception { - var fetcher = HttpFetcher.newFetcher(new URL(url)); + public void newFetcher_urlNoTrailingSlash(String url) throws IOException { + var fetcher = HttpFetcher.newFetcher(URI.create(url)); Assertions.assertEquals("http://example.com/", fetcher.getSource()); } } diff --git a/sigstore-java/src/test/java/dev/sigstore/tuf/UpdaterTest.java b/sigstore-java/src/test/java/dev/sigstore/tuf/UpdaterTest.java index 70591dfb..f63662a5 100644 --- a/sigstore-java/src/test/java/dev/sigstore/tuf/UpdaterTest.java +++ b/sigstore-java/src/test/java/dev/sigstore/tuf/UpdaterTest.java @@ -28,6 +28,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.io.Resources; import com.google.gson.JsonSyntaxException; +import dev.sigstore.http.URIFormat; import dev.sigstore.testkit.tuf.TestResources; import dev.sigstore.tuf.encryption.Verifier; import dev.sigstore.tuf.encryption.Verifiers; @@ -44,7 +45,7 @@ import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URL; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -165,9 +166,8 @@ public void testRootUpdate_newRootHasInvalidSignatures() throws Exception { updater.updateRoot(); Root root = TestResources.loadRoot(localStorePath.resolve("root.json")); assertEquals(2, root.getSignedMeta().getVersion()); - logs.getEvents(); logs.assertContains( - "TUF: ignored invalid signature: 'abcd123' for keyid: '0b5108e406f6d2f59ef767797b314be99d35903950ba43a2d51216eeeb8da98c', because 'exception decoding Hex string: String index out of range: 7'"); + "TUF: ignored invalid signature: 'abcd123' for keyid: '0b5108e406f6d2f59ef767797b314be99d35903950ba43a2d51216eeeb8da98c', because 'exception decoding Hex string:"); } @Test @@ -942,8 +942,9 @@ private static Updater createTimeStaticUpdater(Path localStore, Path trustedRoot return Updater.builder() .setClock(Clock.fixed(Instant.parse(time), ZoneOffset.UTC)) .setVerifiers(Verifiers::newVerifier) - .setMetaFetcher(MetaFetcher.newFetcher(HttpFetcher.newFetcher(new URL(remoteUrl)))) - .setTargetFetcher(HttpFetcher.newFetcher(new URL(remoteUrl + "targets/"))) + .setMetaFetcher(MetaFetcher.newFetcher(HttpFetcher.newFetcher(URI.create(remoteUrl)))) + .setTargetFetcher( + HttpFetcher.newFetcher(URIFormat.appendPath(URI.create(remoteUrl), "targets/"))) .setTrustedRootPath(RootProvider.fromFile(trustedRootFile)) .setTrustedMetaStore( TrustedMetaStore.newTrustedMetaStore( diff --git a/tuf-cli/src/main/java/dev/sigstore/tuf/cli/Tuf.java b/tuf-cli/src/main/java/dev/sigstore/tuf/cli/Tuf.java index e8e28aa1..52a7e3f5 100644 --- a/tuf-cli/src/main/java/dev/sigstore/tuf/cli/Tuf.java +++ b/tuf-cli/src/main/java/dev/sigstore/tuf/cli/Tuf.java @@ -15,7 +15,7 @@ */ package dev.sigstore.tuf.cli; -import java.net.URL; +import java.net.URI; import java.nio.file.Path; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -45,7 +45,7 @@ public CommandSpec getSpec() { names = {"--metadata-url"}, required = false, paramLabel = "") - private URL metadataUrl; + private URI metadataUrl; @Option( names = {"--target-name"}, @@ -57,7 +57,7 @@ public CommandSpec getSpec() { names = {"--target-base-url"}, required = false, paramLabel = "") - private URL targetBaseUrl; + private URI targetBaseUrl; @Option( names = {"--target-dir"}, @@ -72,7 +72,7 @@ Path getMetadataDir() { return metadataDir; } - URL getMetadataUrl() { + URI getMetadataUrl() { if (metadataUrl == null) { throw new ParameterException(spec.commandLine(), "--metadata-url not set"); } @@ -86,7 +86,7 @@ String getTargetName() { return targetName; } - URL getTargetBaseUrl() { + URI getTargetBaseUrl() { if (targetBaseUrl == null) { throw new ParameterException(spec.commandLine(), "--target-base-url not set"); }