From bda63de050eec816f19ffe85b23315545fd96cf0 Mon Sep 17 00:00:00 2001 From: Yisrael Union Date: Tue, 10 Mar 2026 22:22:04 -0400 Subject: [PATCH 1/5] add credentials to cache' --- .../src/main/java/coursierapi/Cache.java | 36 +++++- .../main/java/coursierapi/Credentials.java | 119 +++++++++++++++++- .../coursier/internal/api/ApiHelper.scala | 44 ++++++- 3 files changed, 190 insertions(+), 9 deletions(-) diff --git a/interface/src/main/java/coursierapi/Cache.java b/interface/src/main/java/coursierapi/Cache.java index 5c0bbab..8e36462 100644 --- a/interface/src/main/java/coursierapi/Cache.java +++ b/interface/src/main/java/coursierapi/Cache.java @@ -3,6 +3,10 @@ import coursier.internal.api.ApiHelper; import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutorService; @@ -11,11 +15,13 @@ public final class Cache { private ExecutorService pool; private File location; private Logger logger; + private List credentials; private Cache() { pool = ApiHelper.defaultPool(); location = ApiHelper.defaultLocation(); logger = null; + credentials = Collections.emptyList(); } public static Cache create() { @@ -32,14 +38,15 @@ public boolean equals(Object obj) { Cache other = (Cache) obj; return this.pool.equals(other.pool) && this.location.equals(other.location) && - Objects.equals(this.logger, other.logger); + Objects.equals(this.logger, other.logger) && + this.credentials.equals(other.credentials); } return false; } @Override public int hashCode() { - return 37 * (37 * (17 + pool.hashCode()) + location.hashCode()) + Objects.hashCode(logger); + return 37 * (37 * (37 * (17 + pool.hashCode()) + location.hashCode()) + Objects.hashCode(logger)) + credentials.hashCode(); } @Override @@ -52,6 +59,10 @@ public String toString() { b.append(", logger="); b.append(logger.toString()); } + if (!credentials.isEmpty()) { + b.append(", credentials="); + b.append(credentials.toString()); + } b.append(")"); return b.toString(); } @@ -71,6 +82,23 @@ public Cache withLogger(Logger logger) { return this; } + public Cache withCredentials(List credentials) { + this.credentials = new ArrayList<>(credentials); + return this; + } + + public Cache withCredentials(Credentials... credentials) { + this.credentials = new ArrayList<>(Arrays.asList(credentials)); + return this; + } + + public Cache addCredentials(Credentials... credentials) { + ArrayList newCredentials = new ArrayList<>(this.credentials); + newCredentials.addAll(Arrays.asList(credentials)); + this.credentials = newCredentials; + return this; + } + public ExecutorService getPool() { return pool; } @@ -82,4 +110,8 @@ public File getLocation() { public Logger getLogger() { return logger; } + + public List getCredentials() { + return Collections.unmodifiableList(credentials); + } } diff --git a/interface/src/main/java/coursierapi/Credentials.java b/interface/src/main/java/coursierapi/Credentials.java index 2b9365e..d3a60c7 100644 --- a/interface/src/main/java/coursierapi/Credentials.java +++ b/interface/src/main/java/coursierapi/Credentials.java @@ -1,15 +1,73 @@ package coursierapi; import java.io.Serializable; +import java.util.Objects; public final class Credentials implements Serializable { + private final String host; private final String user; private final String password; + private final String realm; + private final boolean optional; + private final boolean matchHost; + private final boolean httpsOnly; + private final boolean passOnRedirect; - private Credentials(String user, String password) { + private Credentials( + String host, + String user, + String password, + String realm, + boolean optional, + boolean matchHost, + boolean httpsOnly, + boolean passOnRedirect + ) { + this.host = host; this.user = user; this.password = password; + this.realm = realm; + this.optional = optional; + this.matchHost = matchHost; + this.httpsOnly = httpsOnly; + this.passOnRedirect = passOnRedirect; + } + + public static Credentials of(String user, String password) { + return new Credentials("", user, password, null, true, true, false, false); + } + + public static Credentials of(String host, String user, String password) { + return new Credentials(host, user, password, null, true, true, false, false); + } + + public static Credentials of(String host, String user, String password, String realm) { + return new Credentials(host, user, password, realm, true, true, false, false); + } + + public Credentials withHost(String host) { + return new Credentials(host, this.user, this.password, this.realm, this.optional, this.matchHost, this.httpsOnly, this.passOnRedirect); + } + + public Credentials withRealm(String realm) { + return new Credentials(this.host, this.user, this.password, realm, this.optional, this.matchHost, this.httpsOnly, this.passOnRedirect); + } + + public Credentials withOptional(boolean optional) { + return new Credentials(this.host, this.user, this.password, this.realm, optional, this.matchHost, this.httpsOnly, this.passOnRedirect); + } + + public Credentials withMatchHost(boolean matchHost) { + return new Credentials(this.host, this.user, this.password, this.realm, this.optional, matchHost, this.httpsOnly, this.passOnRedirect); + } + + public Credentials withHttpsOnly(boolean httpsOnly) { + return new Credentials(this.host, this.user, this.password, this.realm, this.optional, this.matchHost, httpsOnly, this.passOnRedirect); + } + + public Credentials withPassOnRedirect(boolean passOnRedirect) { + return new Credentials(this.host, this.user, this.password, this.realm, this.optional, this.matchHost, this.httpsOnly, passOnRedirect); } @Override @@ -18,23 +76,52 @@ public boolean equals(Object obj) { return true; if (obj instanceof Credentials) { Credentials other = (Credentials) obj; - return this.user.equals(other.user) && this.password.equals(other.password); + return this.host.equals(other.host) && + this.user.equals(other.user) && + this.password.equals(other.password) && + Objects.equals(this.realm, other.realm) && + this.optional == other.optional && + this.matchHost == other.matchHost && + this.httpsOnly == other.httpsOnly && + this.passOnRedirect == other.passOnRedirect; } return false; } @Override public int hashCode() { - return 37 * (17 + user.hashCode()) + password.hashCode(); + int h = 17; + h = 37 * h + host.hashCode(); + h = 37 * h + user.hashCode(); + h = 37 * h + password.hashCode(); + h = 37 * h + Objects.hashCode(realm); + h = 37 * h + Boolean.hashCode(optional); + h = 37 * h + Boolean.hashCode(matchHost); + h = 37 * h + Boolean.hashCode(httpsOnly); + h = 37 * h + Boolean.hashCode(passOnRedirect); + return h; } @Override public String toString() { - return "Credentials(" + user + ", ****)"; + StringBuilder b = new StringBuilder("Credentials("); + if (!host.isEmpty()) { + b.append("host="); + b.append(host); + b.append(", "); + } + b.append(user); + b.append(", ****"); + if (realm != null) { + b.append(", realm="); + b.append(realm); + } + b.append(")"); + return b.toString(); } - public static Credentials of(String user, String password) { - return new Credentials(user, password); + public String getHost() { + return host; } public String getUser() { @@ -44,4 +131,24 @@ public String getUser() { public String getPassword() { return password; } + + public String getRealm() { + return realm; + } + + public boolean isOptional() { + return optional; + } + + public boolean isMatchHost() { + return matchHost; + } + + public boolean isHttpsOnly() { + return httpsOnly; + } + + public boolean isPassOnRedirect() { + return passOnRedirect; + } } diff --git a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala index b1608d6..be90dc9 100644 --- a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala +++ b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala @@ -72,7 +72,38 @@ object ApiHelper { if (credentials == null) None else - Some(Authentication(credentials.getUser, credentials.getPassword)) + Some(Authentication( + credentials.getUser, + Option(credentials.getPassword), + realmOpt = Option(credentials.getRealm), + optional = credentials.isOptional, + httpsOnly = credentials.isHttpsOnly, + passOnRedirect = credentials.isPassOnRedirect + )) + + def directCredentials(credentials: Credentials): coursier.credentials.DirectCredentials = + coursier.credentials.DirectCredentials( + credentials.getHost, + Some(credentials.getUser), + Some(coursier.credentials.Password(credentials.getPassword)), + Option(credentials.getRealm), + credentials.isOptional, + credentials.isMatchHost, + credentials.isHttpsOnly, + credentials.isPassOnRedirect + ) + + def credentials(dc: coursier.credentials.DirectCredentials): Credentials = + Credentials.of( + dc.host, + dc.usernameOpt.getOrElse(""), + dc.passwordOpt.map(_.value).getOrElse("") + ) + .withRealm(dc.realm.orNull) + .withOptional(dc.optional) + .withMatchHost(dc.matchHost) + .withHttpsOnly(dc.httpsOnly) + .withPassOnRedirect(dc.passOnRedirect) private[this] def ivyRepository(ivy: coursierapi.IvyRepository): IvyRepository = IvyRepository.parse( @@ -336,10 +367,15 @@ object ApiHelper { } } + val cacheCredentials = cache.getCredentials.asScala + .map(directCredentials) + .map(dc => dc: coursier.credentials.Credentials) + FileCache() .withPool(cache.getPool) .withLocation(cache.getLocation) .withLogger(loggerOpt.getOrElse(CacheLogger.nop)) + .withCredentials(cacheCredentials.toSeq) } def cache(cache: FileCache[Task]): coursierapi.Cache = { @@ -349,10 +385,16 @@ object ApiHelper { case logger => Some(WrappedLogger.of(logger)) } + val cacheCredentials = cache.credentials.flatMap { + case dc: coursier.credentials.DirectCredentials => Seq(credentials(dc)) + case other => other.get().map(credentials) + } + coursierapi.Cache.create() .withPool(cache.pool) .withLocation(cache.location) .withLogger(loggerOpt.orNull) + .withCredentials(cacheCredentials.asJava) } def fetch(fetch: coursierapi.Fetch): Fetch[Task] = { From ede564fecccba20f090bc1cf477ddcf5bea72bed Mon Sep 17 00:00:00 2001 From: Yisrael Union Date: Tue, 17 Mar 2026 11:32:53 -0400 Subject: [PATCH 2/5] expose support at the top level --- .../src/main/java/coursierapi/Complete.java | 5 + .../src/main/java/coursierapi/Fetch.java | 5 + .../src/main/java/coursierapi/Versions.java | 5 + .../coursier/internal/api/ApiHelper.scala | 2 +- .../test/scala/coursierapi/CacheTests.scala | 161 ++++++++++++++++++ 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 interface/src/test/scala/coursierapi/CacheTests.scala diff --git a/interface/src/main/java/coursierapi/Complete.java b/interface/src/main/java/coursierapi/Complete.java index 6990899..c197250 100644 --- a/interface/src/main/java/coursierapi/Complete.java +++ b/interface/src/main/java/coursierapi/Complete.java @@ -82,6 +82,11 @@ public Complete withCache(Cache cache) { return this; } + public Complete addCredentials(Credentials... credentials) { + this.cache = this.cache.addCredentials(credentials); + return this; + } + public Complete withInput(String input) { this.input = input; return this; diff --git a/interface/src/main/java/coursierapi/Fetch.java b/interface/src/main/java/coursierapi/Fetch.java index 33b489f..06c561a 100644 --- a/interface/src/main/java/coursierapi/Fetch.java +++ b/interface/src/main/java/coursierapi/Fetch.java @@ -118,6 +118,11 @@ public Fetch withCache(Cache cache) { return this; } + public Fetch addCredentials(Credentials... credentials) { + this.cache = this.cache.addCredentials(credentials); + return this; + } + public Fetch withMainArtifacts(Boolean mainArtifacts) { this.mainArtifacts = mainArtifacts; return this; diff --git a/interface/src/main/java/coursierapi/Versions.java b/interface/src/main/java/coursierapi/Versions.java index 4830fbf..89f2c3a 100644 --- a/interface/src/main/java/coursierapi/Versions.java +++ b/interface/src/main/java/coursierapi/Versions.java @@ -68,6 +68,11 @@ public Versions withCache(Cache cache) { return this; } + public Versions addCredentials(Credentials... credentials) { + this.cache = this.cache.addCredentials(credentials); + return this; + } + public Versions withModule(Module module) { this.module = module; return this; diff --git a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala index be90dc9..1c0d7e8 100644 --- a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala +++ b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala @@ -375,7 +375,7 @@ object ApiHelper { .withPool(cache.getPool) .withLocation(cache.getLocation) .withLogger(loggerOpt.getOrElse(CacheLogger.nop)) - .withCredentials(cacheCredentials.toSeq) + .addCredentials(cacheCredentials.toSeq: _*) } def cache(cache: FileCache[Task]): coursierapi.Cache = { diff --git a/interface/src/test/scala/coursierapi/CacheTests.scala b/interface/src/test/scala/coursierapi/CacheTests.scala new file mode 100644 index 0000000..fa70cf5 --- /dev/null +++ b/interface/src/test/scala/coursierapi/CacheTests.scala @@ -0,0 +1,161 @@ +package coursierapi + +import coursier.cache.{CacheEnv, FileCache} +import coursier.credentials.{DirectCredentials, FileCredentials} +import coursier.internal.api.ApiHelper +import coursier.util.EnvValues +import utest._ + +import java.nio.file.Files + +object CacheTests extends TestSuite { + + val tests = Tests { + + test("credentials") { + + test("configFileCredentialsAutoLoaded") { + val tmpFile = Files.createTempFile("test-credentials", ".properties") + try { + val content = + """myrepo.host=artifacts.corp.com + |myrepo.username=testuser + |myrepo.password=testpass + |""".stripMargin + Files.write(tmpFile, content.getBytes) + + val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toString), None) + val noEnv = EnvValues(None, None) + val defaultCreds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) + + val fc = FileCache().withCredentials(defaultCreds) + val resolved = fc.credentials.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + + assert(resolved.exists(_.host == "artifacts.corp.com")) + assert(resolved.exists(dc => dc.usernameOpt.contains("testuser"))) + assert(resolved.exists(dc => dc.passwordOpt.exists(_.value == "testpass"))) + } finally { + Files.deleteIfExists(tmpFile) + } + } + + test("defaultCredentialsNotOverridden") { + val cache = Cache.create() + val fc = ApiHelper.cache(cache) + val defaultFc = FileCache() + assert(fc.credentials == defaultFc.credentials) + } + + test("explicitCredentialsAppendedToDefaults") { + val cache = Cache.create() + .addCredentials(Credentials.of("custom.host.com", "myuser", "mypass")) + val fc = ApiHelper.cache(cache) + val defaultFc = FileCache() + assert(fc.credentials.size >= defaultFc.credentials.size + 1) + val allDirect = fc.credentials.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + assert(allDirect.exists(_.host == "custom.host.com")) + } + + test("multipleCredentialsFromPropertiesFile") { + val tmpFile = Files.createTempFile("test-credentials-multi", ".properties") + try { + val content = + """repo1.host=host1.example.com + |repo1.username=user1 + |repo1.password=pass1 + |repo2.host=host2.example.com + |repo2.username=user2 + |repo2.password=pass2 + |repo2.realm=MyRealm + |repo2.https-only=true + |""".stripMargin + Files.write(tmpFile, content.getBytes) + + val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toString), None) + val noEnv = EnvValues(None, None) + val defaultCreds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) + + val resolved = defaultCreds.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + + assert(resolved.length == 2) + val hosts = resolved.map(_.host).toSet + assert(hosts == Set("host1.example.com", "host2.example.com")) + + val repo2 = resolved.find(_.host == "host2.example.com").get + assert(repo2.realm.contains("MyRealm")) + assert(repo2.httpsOnly) + + // Verify round-trip through Java API conversion + val apiCred = ApiHelper.credentials(repo2) + assert(apiCred.getHost == "host2.example.com") + assert(apiCred.getRealm == "MyRealm") + assert(apiCred.isHttpsOnly) + + val backToScala = ApiHelper.directCredentials(apiCred) + assert(backToScala.host == "host2.example.com") + assert(backToScala.realm.contains("MyRealm")) + assert(backToScala.httpsOnly) + } finally { + Files.deleteIfExists(tmpFile) + } + } + + test("fetchAddCredentials") { + val fetch = Fetch.create() + .addCredentials(Credentials.of("fetch.host.com", "fuser", "fpass")) + val creds = fetch.getCache.getCredentials + assert(creds.size == 1) + assert(creds.get(0).getHost == "fetch.host.com") + assert(creds.get(0).getUser == "fuser") + + // Verify flows through to FileCache + val fc = ApiHelper.cache(fetch.getCache) + val allDirect = fc.credentials.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + assert(allDirect.exists(_.host == "fetch.host.com")) + } + + test("completeAddCredentials") { + val complete = Complete.create() + .addCredentials(Credentials.of("complete.host.com", "cuser", "cpass")) + val creds = complete.getCache.getCredentials + assert(creds.size == 1) + assert(creds.get(0).getHost == "complete.host.com") + } + + test("versionsAddCredentials") { + val versions = Versions.create() + .addCredentials(Credentials.of("versions.host.com", "vuser", "vpass")) + val creds = versions.getCache.getCredentials + assert(creds.size == 1) + assert(creds.get(0).getHost == "versions.host.com") + } + + test("allPathsPreserveDefaults") { + // Verify that Fetch, Complete, Versions all get default credentials + // when no explicit credentials are set + val defaultFc = FileCache() + + val fetchFc = ApiHelper.cache(Fetch.create().getCache) + assert(fetchFc.credentials == defaultFc.credentials) + + val completeFc = ApiHelper.cache(Complete.create().getCache) + assert(completeFc.credentials == defaultFc.credentials) + + val versionsFc = ApiHelper.cache(Versions.create().getCache) + assert(versionsFc.credentials == defaultFc.credentials) + } + } + } +} From d073d361d7b08f9a45ce365e581f30d24a922022 Mon Sep 17 00:00:00 2001 From: Yisrael Union Date: Tue, 17 Mar 2026 11:50:59 -0400 Subject: [PATCH 3/5] fix round tripping from java to scala realm/optional/httpsOnly/passOnRedirect were being dropped in the credentials(Authentication) and credentials(Credentials) round-trip methods at lines --- .../coursier/internal/api/ApiHelper.scala | 15 ++- .../test/scala/coursierapi/CacheTests.scala | 113 +++++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala index 1c0d7e8..007542a 100644 --- a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala +++ b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala @@ -254,10 +254,21 @@ object ApiHelper { } def credentials(auth: Authentication): Credentials = - coursierapi.Credentials.of(auth.user, auth.passwordOpt.getOrElse("")) + coursierapi.Credentials.of(auth.userOpt.getOrElse(""), auth.passwordOpt.getOrElse("")) + .withRealm(auth.realmOpt.orNull) + .withOptional(auth.optional) + .withHttpsOnly(auth.httpsOnly) + .withPassOnRedirect(auth.passOnRedirect) def credentials(credentials: Credentials): Authentication = - Authentication(credentials.getUser, credentials.getPassword) + Authentication( + credentials.getUser, + Option(credentials.getPassword), + optional = credentials.isOptional, + realmOpt = Option(credentials.getRealm), + httpsOnly = credentials.isHttpsOnly, + passOnRedirect = credentials.isPassOnRedirect + ) def repository(repo: Repository): coursierapi.Repository = repo match { diff --git a/interface/src/test/scala/coursierapi/CacheTests.scala b/interface/src/test/scala/coursierapi/CacheTests.scala index fa70cf5..6c12cc8 100644 --- a/interface/src/test/scala/coursierapi/CacheTests.scala +++ b/interface/src/test/scala/coursierapi/CacheTests.scala @@ -7,6 +7,7 @@ import coursier.util.EnvValues import utest._ import java.nio.file.Files +import java.util object CacheTests extends TestSuite { @@ -117,7 +118,6 @@ object CacheTests extends TestSuite { assert(creds.get(0).getHost == "fetch.host.com") assert(creds.get(0).getUser == "fuser") - // Verify flows through to FileCache val fc = ApiHelper.cache(fetch.getCache) val allDirect = fc.credentials.flatMap { case dc: DirectCredentials => Seq(dc) @@ -143,8 +143,6 @@ object CacheTests extends TestSuite { } test("allPathsPreserveDefaults") { - // Verify that Fetch, Complete, Versions all get default credentials - // when no explicit credentials are set val defaultFc = FileCache() val fetchFc = ApiHelper.cache(Fetch.create().getCache) @@ -155,6 +153,115 @@ object CacheTests extends TestSuite { val versionsFc = ApiHelper.cache(Versions.create().getCache) assert(versionsFc.credentials == defaultFc.credentials) + + val archiveFc = ApiHelper.cache(ArchiveCache.create().getCache) + assert(archiveFc.credentials == defaultFc.credentials) + } + } + + test("credentialsApi") { + + test("factoryMethods") { + val c1 = Credentials.of("user", "pass") + assert(c1.getHost == "") + assert(c1.getUser == "user") + assert(c1.getPassword == "pass") + assert(c1.getRealm == null) + + val c2 = Credentials.of("myhost.com", "user", "pass") + assert(c2.getHost == "myhost.com") + + val c3 = Credentials.of("myhost.com", "user", "pass", "MyRealm") + assert(c3.getRealm == "MyRealm") + } + + test("builderMethods") { + val cred = Credentials.of("user", "pass") + .withHost("builder.host.com") + .withRealm("TestRealm") + .withOptional(false) + .withMatchHost(false) + .withHttpsOnly(true) + .withPassOnRedirect(true) + + assert(cred.getHost == "builder.host.com") + assert(cred.getRealm == "TestRealm") + assert(!cred.isOptional) + assert(!cred.isMatchHost) + assert(cred.isHttpsOnly) + assert(cred.isPassOnRedirect) + } + + test("equalsAndHashCode") { + val c1 = Credentials.of("host.com", "user", "pass", "realm") + .withHttpsOnly(true) + val c2 = Credentials.of("host.com", "user", "pass", "realm") + .withHttpsOnly(true) + val c3 = Credentials.of("other.com", "user", "pass", "realm") + + assert(c1 == c2) + assert(c1.hashCode == c2.hashCode) + assert(c1 != c3) + } + + test("cacheWithCredentialsList") { + val list = new util.ArrayList[Credentials]() + list.add(Credentials.of("h1.com", "u1", "p1")) + list.add(Credentials.of("h2.com", "u2", "p2")) + + val cache = Cache.create().withCredentials(list) + assert(cache.getCredentials.size == 2) + + // withCredentials(varargs) overload + val cache2 = Cache.create().withCredentials( + Credentials.of("h3.com", "u3", "p3"), + Credentials.of("h4.com", "u4", "p4") + ) + assert(cache2.getCredentials.size == 2) + assert(cache2.getCredentials.get(0).getHost == "h3.com") + assert(cache2.getCredentials.get(1).getHost == "h4.com") + } + + test("cacheAddCredentialsAccumulates") { + val cache = Cache.create() + .addCredentials(Credentials.of("h1.com", "u1", "p1")) + .addCredentials(Credentials.of("h2.com", "u2", "p2")) + assert(cache.getCredentials.size == 2) + assert(cache.getCredentials.get(0).getHost == "h1.com") + assert(cache.getCredentials.get(1).getHost == "h2.com") + } + + test("credentialsImmutableFromGetter") { + val cache = Cache.create() + .addCredentials(Credentials.of("h1.com", "u1", "p1")) + val thrown = try { + cache.getCredentials.add(Credentials.of("h2.com", "u2", "p2")) + false + } catch { + case _: UnsupportedOperationException => true + } + assert(thrown) + } + + test("perRepoCredentialsWithAllFields") { + val cred = Credentials.of("admin", "secret") + .withRealm("CorpRealm") + .withHttpsOnly(true) + .withPassOnRedirect(true) + + val repo = MavenRepository.of("https://repo.corp.com/releases") + .withCredentials(cred) + + val repo0 = ApiHelper.repository(ApiHelper.repository(repo)) + assert(repo == repo0) + + // Verify all credential fields survive the round-trip + val repoCred = repo0.asInstanceOf[MavenRepository].getCredentials + assert(repoCred.getUser == "admin") + assert(repoCred.getPassword == "secret") + assert(repoCred.getRealm == "CorpRealm") + assert(repoCred.isHttpsOnly) + assert(repoCred.isPassOnRedirect) } } } From 803aee97fb26a8a7c5e0c4704ab57547c9b1fd75 Mon Sep 17 00:00:00 2001 From: Yisrael Union Date: Tue, 17 Mar 2026 12:08:33 -0400 Subject: [PATCH 4/5] add file credentials --- .../src/main/java/coursierapi/Cache.java | 26 ++++++++- .../src/main/java/coursierapi/Complete.java | 5 ++ .../src/main/java/coursierapi/Fetch.java | 5 ++ .../src/main/java/coursierapi/Versions.java | 5 ++ .../coursier/internal/api/ApiHelper.scala | 13 ++++- .../test/scala/coursierapi/CacheTests.scala | 55 +++++++++++++++++++ 6 files changed, 105 insertions(+), 4 deletions(-) diff --git a/interface/src/main/java/coursierapi/Cache.java b/interface/src/main/java/coursierapi/Cache.java index 8e36462..6a37fba 100644 --- a/interface/src/main/java/coursierapi/Cache.java +++ b/interface/src/main/java/coursierapi/Cache.java @@ -16,12 +16,14 @@ public final class Cache { private File location; private Logger logger; private List credentials; + private List credentialFiles; private Cache() { pool = ApiHelper.defaultPool(); location = ApiHelper.defaultLocation(); logger = null; credentials = Collections.emptyList(); + credentialFiles = Collections.emptyList(); } public static Cache create() { @@ -39,14 +41,15 @@ public boolean equals(Object obj) { return this.pool.equals(other.pool) && this.location.equals(other.location) && Objects.equals(this.logger, other.logger) && - this.credentials.equals(other.credentials); + this.credentials.equals(other.credentials) && + this.credentialFiles.equals(other.credentialFiles); } return false; } @Override public int hashCode() { - return 37 * (37 * (37 * (17 + pool.hashCode()) + location.hashCode()) + Objects.hashCode(logger)) + credentials.hashCode(); + return 37 * (37 * (37 * (37 * (17 + pool.hashCode()) + location.hashCode()) + Objects.hashCode(logger)) + credentials.hashCode()) + credentialFiles.hashCode(); } @Override @@ -63,6 +66,10 @@ public String toString() { b.append(", credentials="); b.append(credentials.toString()); } + if (!credentialFiles.isEmpty()) { + b.append(", credentialFiles="); + b.append(credentialFiles.toString()); + } b.append(")"); return b.toString(); } @@ -99,6 +106,17 @@ public Cache addCredentials(Credentials... credentials) { return this; } + public Cache addFileCredentials(String path) { + ArrayList newFiles = new ArrayList<>(this.credentialFiles); + newFiles.add(path); + this.credentialFiles = newFiles; + return this; + } + + public Cache addFileCredentials(File file) { + return addFileCredentials(file.getAbsolutePath()); + } + public ExecutorService getPool() { return pool; } @@ -114,4 +132,8 @@ public Logger getLogger() { public List getCredentials() { return Collections.unmodifiableList(credentials); } + + public List getCredentialFiles() { + return Collections.unmodifiableList(credentialFiles); + } } diff --git a/interface/src/main/java/coursierapi/Complete.java b/interface/src/main/java/coursierapi/Complete.java index c197250..f9e751b 100644 --- a/interface/src/main/java/coursierapi/Complete.java +++ b/interface/src/main/java/coursierapi/Complete.java @@ -87,6 +87,11 @@ public Complete addCredentials(Credentials... credentials) { return this; } + public Complete addFileCredentials(String path) { + this.cache = this.cache.addFileCredentials(path); + return this; + } + public Complete withInput(String input) { this.input = input; return this; diff --git a/interface/src/main/java/coursierapi/Fetch.java b/interface/src/main/java/coursierapi/Fetch.java index 06c561a..e767492 100644 --- a/interface/src/main/java/coursierapi/Fetch.java +++ b/interface/src/main/java/coursierapi/Fetch.java @@ -123,6 +123,11 @@ public Fetch addCredentials(Credentials... credentials) { return this; } + public Fetch addFileCredentials(String path) { + this.cache = this.cache.addFileCredentials(path); + return this; + } + public Fetch withMainArtifacts(Boolean mainArtifacts) { this.mainArtifacts = mainArtifacts; return this; diff --git a/interface/src/main/java/coursierapi/Versions.java b/interface/src/main/java/coursierapi/Versions.java index 89f2c3a..9d9c566 100644 --- a/interface/src/main/java/coursierapi/Versions.java +++ b/interface/src/main/java/coursierapi/Versions.java @@ -73,6 +73,11 @@ public Versions addCredentials(Credentials... credentials) { return this; } + public Versions addFileCredentials(String path) { + this.cache = this.cache.addFileCredentials(path); + return this; + } + public Versions withModule(Module module) { this.module = module; return this; diff --git a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala index 007542a..b5322f3 100644 --- a/interface/src/main/scala/coursier/internal/api/ApiHelper.scala +++ b/interface/src/main/scala/coursier/internal/api/ApiHelper.scala @@ -382,11 +382,14 @@ object ApiHelper { .map(directCredentials) .map(dc => dc: coursier.credentials.Credentials) + val fileCredentials = cache.getCredentialFiles.asScala + .map(path => coursier.credentials.FileCredentials(path, optional = true): coursier.credentials.Credentials) + FileCache() .withPool(cache.getPool) .withLocation(cache.getLocation) .withLogger(loggerOpt.getOrElse(CacheLogger.nop)) - .addCredentials(cacheCredentials.toSeq: _*) + .addCredentials((cacheCredentials ++ fileCredentials).toSeq: _*) } def cache(cache: FileCache[Task]): coursierapi.Cache = { @@ -398,14 +401,20 @@ object ApiHelper { val cacheCredentials = cache.credentials.flatMap { case dc: coursier.credentials.DirectCredentials => Seq(credentials(dc)) + case _: coursier.credentials.FileCredentials => Nil case other => other.get().map(credentials) } - coursierapi.Cache.create() + val fileCredentialPaths = cache.credentials.collect { + case fc: coursier.credentials.FileCredentials => fc.path + } + + val c = coursierapi.Cache.create() .withPool(cache.pool) .withLocation(cache.location) .withLogger(loggerOpt.orNull) .withCredentials(cacheCredentials.asJava) + fileCredentialPaths.foldLeft(c)((c, path) => c.addFileCredentials(path)) } def fetch(fetch: coursierapi.Fetch): Fetch[Task] = { diff --git a/interface/src/test/scala/coursierapi/CacheTests.scala b/interface/src/test/scala/coursierapi/CacheTests.scala index 6c12cc8..68125bc 100644 --- a/interface/src/test/scala/coursierapi/CacheTests.scala +++ b/interface/src/test/scala/coursierapi/CacheTests.scala @@ -243,6 +243,61 @@ object CacheTests extends TestSuite { assert(thrown) } + test("fileCredentialsPassedToFileCache") { + val tmpFile = Files.createTempFile("test-file-creds", ".properties") + try { + val content = + """myrepo.host=filecred.example.com + |myrepo.username=fileuser + |myrepo.password=filepass + |""".stripMargin + Files.write(tmpFile, content.getBytes) + + val cache = Cache.create() + .addFileCredentials(tmpFile.toAbsolutePath.toString) + val fc = ApiHelper.cache(cache) + + // FileCredentials should be present in the FileCache credentials + val hasFileCred = fc.credentials.exists { + case fc: FileCredentials => fc.path == tmpFile.toAbsolutePath.toString + case _ => false + } + assert(hasFileCred) + + // Resolve and verify the credentials load correctly + val resolved = fc.credentials.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + assert(resolved.exists(_.host == "filecred.example.com")) + assert(resolved.exists(dc => dc.usernameOpt.contains("fileuser"))) + } finally { + Files.deleteIfExists(tmpFile) + } + } + + test("fileCredentialsRoundTrip") { + val cache = Cache.create() + .addFileCredentials("/some/path/credentials.properties") + .addFileCredentials("/other/path/creds.properties") + + // Java API -> Scala FileCache -> Java API + val fc = ApiHelper.cache(cache) + val cache2 = ApiHelper.cache(fc) + + // Round-trip includes default FileCredentials from CacheDefaults plus our explicit ones + val files = cache2.getCredentialFiles + assert(files.contains("/some/path/credentials.properties")) + assert(files.contains("/other/path/creds.properties")) + } + + test("fetchAddFileCredentials") { + val fetch = Fetch.create() + .addFileCredentials("/my/credentials.properties") + assert(fetch.getCache.getCredentialFiles.size == 1) + assert(fetch.getCache.getCredentialFiles.get(0) == "/my/credentials.properties") + } + test("perRepoCredentialsWithAllFields") { val cred = Credentials.of("admin", "secret") .withRealm("CorpRealm") From 49c37a46ae772285c6f529346856911aea6a371d Mon Sep 17 00:00:00 2001 From: Yisrael Union Date: Tue, 17 Mar 2026 16:56:08 -0400 Subject: [PATCH 5/5] fix tests on windows --- .../test/scala/coursierapi/CacheTests.scala | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/interface/src/test/scala/coursierapi/CacheTests.scala b/interface/src/test/scala/coursierapi/CacheTests.scala index 68125bc..a43d3a8 100644 --- a/interface/src/test/scala/coursierapi/CacheTests.scala +++ b/interface/src/test/scala/coursierapi/CacheTests.scala @@ -25,7 +25,7 @@ object CacheTests extends TestSuite { |""".stripMargin Files.write(tmpFile, content.getBytes) - val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toString), None) + val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toUri.toString), None) val noEnv = EnvValues(None, None) val defaultCreds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) @@ -78,7 +78,7 @@ object CacheTests extends TestSuite { |""".stripMargin Files.write(tmpFile, content.getBytes) - val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toString), None) + val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toUri.toString), None) val noEnv = EnvValues(None, None) val defaultCreds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) @@ -142,6 +142,68 @@ object CacheTests extends TestSuite { assert(creds.get(0).getHost == "versions.host.com") } + test("coursierCredentialsEnvInlineCredentials") { + // Simulates COURSIER_CREDENTIALS env var with inline credentials + val credEnv = EnvValues(Some("artifacts.corp.com user:password"), None) + val noEnv = EnvValues(None, None) + val creds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) + + val resolved = creds.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + + assert(resolved.exists(_.host == "artifacts.corp.com")) + assert(resolved.exists(dc => dc.usernameOpt.contains("user"))) + assert(resolved.exists(dc => dc.passwordOpt.exists(_.value == "password"))) + } + + test("coursierCredentialsEnvFileCredentials") { + // Simulates COURSIER_CREDENTIALS env var pointing to a properties file + val tmpFile = Files.createTempFile("test-env-credentials", ".properties") + try { + val content = + """myrepo.host=env-file.example.com + |myrepo.username=envuser + |myrepo.password=envpass + |""".stripMargin + Files.write(tmpFile, content.getBytes) + + val credEnv = EnvValues(Some(tmpFile.toAbsolutePath.toUri.toString), None) + val noEnv = EnvValues(None, None) + val creds = CacheEnv.defaultCredentials(credEnv, noEnv, noEnv) + + val resolved = creds.flatMap { + case dc: DirectCredentials => Seq(dc) + case other => other.get() + } + + assert(resolved.exists(_.host == "env-file.example.com")) + assert(resolved.exists(dc => dc.usernameOpt.contains("envuser"))) + assert(resolved.exists(dc => dc.passwordOpt.exists(_.value == "envpass"))) + } finally { + Files.deleteIfExists(tmpFile) + } + } + + test("coursierCredentialsEnvPreservedThroughApiHelper") { + // Proves that FileCache() defaults (which include COURSIER_CREDENTIALS) + // are preserved when going through ApiHelper.cache(Cache.create()) + val cache = Cache.create() + val fc = ApiHelper.cache(cache) + val defaultFc = FileCache() + + // The credentials from FileCache defaults (which reads COURSIER_CREDENTIALS) + // should be present in the FileCache created by ApiHelper + assert(fc.credentials == defaultFc.credentials) + + // Adding explicit credentials should not displace the defaults + val cacheWithExtra = Cache.create() + .addCredentials(Credentials.of("extra.host.com", "eu", "ep")) + val fcWithExtra = ApiHelper.cache(cacheWithExtra) + assert(fcWithExtra.credentials.size == defaultFc.credentials.size + 1) + } + test("allPathsPreserveDefaults") { val defaultFc = FileCache()