From fbdbb4875290b30a424b5d31ff3fa4fded65a39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Sat, 14 Mar 2026 17:34:36 +0100 Subject: [PATCH 1/5] feat(services): add native karaf.url service as mvn: protocol handler Add a new URL handler service under services/url that provides a native replacement for pax-url-mvn. The service registers a URLStreamHandlerService for the mvn: protocol and exposes a MavenResolver API for programmatic artifact resolution from local and remote Maven repositories. --- services/pom.xml | 1 + services/url/pom.xml | 134 +++++++ .../karaf/services/url/MavenResolver.java | 61 +++ .../url/internal/MavenConfiguration.java | 230 +++++++++++ .../url/internal/MavenResolverImpl.java | 362 ++++++++++++++++++ .../services/url/internal/MvnUrlHandler.java | 95 +++++ .../services/url/internal/osgi/Activator.java | 52 +++ .../url/src/main/resources/META-INF/LICENSE | 202 ++++++++++ .../url/src/main/resources/META-INF/NOTICE | 9 + .../resources/org.apache.karaf.url.mvn.cfg | 84 ++++ .../url/internal/MavenConfigurationTest.java | 290 ++++++++++++++ .../url/internal/MavenResolverImplTest.java | 319 +++++++++++++++ .../url/internal/MvnUrlHandlerTest.java | 162 ++++++++ 13 files changed, 2001 insertions(+) create mode 100644 services/url/pom.xml create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java create mode 100644 services/url/src/main/resources/META-INF/LICENSE create mode 100644 services/url/src/main/resources/META-INF/NOTICE create mode 100644 services/url/src/main/resources/org.apache.karaf.url.mvn.cfg create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/MavenConfigurationTest.java create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java diff --git a/services/pom.xml b/services/pom.xml index c53e17dd544..afab895e89e 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -38,6 +38,7 @@ eventadmin staticcm interceptor + url diff --git a/services/url/pom.xml b/services/url/pom.xml new file mode 100644 index 00000000000..de92c6b3580 --- /dev/null +++ b/services/url/pom.xml @@ -0,0 +1,134 @@ + + + + + + 4.0.0 + + + karaf + org.apache.karaf + 4.5.0-SNAPSHOT + ../../pom.xml + + + org.apache.karaf.services + org.apache.karaf.services.url + bundle + Apache Karaf :: OSGi Services :: URL Handlers + Karaf URL Handlers Service (mvn: protocol) + + + + + org.apache.karaf.tooling + karaf-services-maven-plugin + + + org.apache.felix + maven-bundle-plugin + + + + ${project.artifactId} + + + org.apache.karaf.services.url.internal.osgi.Activator + + The Apache Software Foundation + + org.osgi.framework;version="[1,3)", + org.osgi.service.url;version="[1.0,2)", + org.osgi.service.cm;version="[1.5,2)";resolution:=optional, + org.osgi.util.tracker;version="[1.5,2)", + javax.net.ssl, + javax.xml.parsers, + org.w3c.dom, + org.xml.sax, + * + + + org.apache.karaf.services.url;version="${project.version}" + + + org.apache.karaf.services.url.internal, + org.apache.karaf.services.url.internal.osgi + + + org.osgi.service.url.URLStreamHandlerService, + org.apache.karaf.services.url.MavenResolver + + + + + + + + + + + org.apache.karaf + karaf-bom + ${project.version} + pom + import + + + + + + + org.osgi + org.osgi.framework + + + org.osgi + org.osgi.service.url + + + org.osgi + org.osgi.service.cm + provided + + + org.osgi + org.osgi.util.tracker + + + org.apache.karaf + org.apache.karaf.util + provided + + + org.slf4j + slf4j-api + + + junit + junit + test + + + org.easymock + easymock + test + + + + diff --git a/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java b/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java new file mode 100644 index 00000000000..529b0034406 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url; + +import java.io.File; +import java.io.IOException; + +/** + * Service interface for resolving Maven artifact URIs. + *

+ * Maven URIs follow the syntax: + * {@code mvn:[repository_url!]groupId/artifactId[/[version][/[type][/classifier]]]} + */ +public interface MavenResolver { + + /** + * Resolve a Maven artifact URI to a local file. + * + * @param url the Maven artifact URI (e.g. {@code mvn:groupId/artifactId/version}) + * @return the resolved file + * @throws IOException if the artifact cannot be resolved + */ + File resolve(String url) throws IOException; + + /** + * Resolve a Maven artifact by its coordinates to a local file. + * + * @param groupId the group ID + * @param artifactId the artifact ID + * @param version the version (can be {@code null} for LATEST) + * @param type the type/extension (can be {@code null} for jar) + * @param classifier the classifier (can be {@code null}) + * @return the resolved file + * @throws IOException if the artifact cannot be resolved + */ + File resolve(String groupId, String artifactId, String version, String type, String classifier) throws IOException; + + /** + * Get the local repository path. + * + * @return the local repository directory + */ + File getLocalRepository(); + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java new file mode 100644 index 00000000000..bf814caa4d0 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Dictionary; +import java.util.List; + +import org.osgi.framework.BundleContext; + +/** + * Configuration for the Maven URL resolver. + */ +public class MavenConfiguration { + + public static final String PID = "org.apache.karaf.url.mvn"; + + public static final String PROP_LOCAL_REPOSITORY = "localRepository"; + public static final String PROP_DEFAULT_REPOSITORIES = "defaultRepositories"; + public static final String PROP_REPOSITORIES = "repositories"; + public static final String PROP_UPDATE_POLICY = "globalUpdatePolicy"; + public static final String PROP_CHECKSUM_POLICY = "globalChecksumPolicy"; + public static final String PROP_SETTINGS = "settings"; + public static final String PROP_CONNECTION_TIMEOUT = "socket.connectionTimeout"; + public static final String PROP_READ_TIMEOUT = "socket.readTimeout"; + public static final String PROP_TIMEOUT = "timeout"; + public static final String PROP_CERTIFICATE_CHECK = "certificateCheck"; + public static final String PROP_RETRY_COUNT = "connection.retryCount"; + + private File localRepository; + private List defaultRepositories; + private List repositories; + private String updatePolicy; + private String checksumPolicy; + private int connectionTimeout; + private int readTimeout; + private boolean certificateCheck; + private int retryCount; + + public MavenConfiguration(BundleContext context, Dictionary properties) { + // Local repository + String localRepo = getProperty(context, properties, PROP_LOCAL_REPOSITORY); + if (localRepo != null && !localRepo.isEmpty()) { + localRepository = resolveFile(context, localRepo); + } else { + localRepository = new File(System.getProperty("user.home"), ".m2/repository"); + } + + // Default repositories (local file-based repos) + String defaultRepos = getProperty(context, properties, PROP_DEFAULT_REPOSITORIES); + defaultRepositories = parseRepositories(context, defaultRepos); + + // Remote repositories + String repos = getProperty(context, properties, PROP_REPOSITORIES); + repositories = parseRepositories(context, repos); + + // Policies + updatePolicy = getProperty(context, properties, PROP_UPDATE_POLICY); + checksumPolicy = getProperty(context, properties, PROP_CHECKSUM_POLICY); + + // Timeouts + int defaultTimeout = getIntProperty(context, properties, PROP_TIMEOUT, 5000); + connectionTimeout = getIntProperty(context, properties, PROP_CONNECTION_TIMEOUT, defaultTimeout); + readTimeout = getIntProperty(context, properties, PROP_READ_TIMEOUT, 30000); + + // SSL + certificateCheck = getBooleanProperty(context, properties, PROP_CERTIFICATE_CHECK, true); + + // Retry + retryCount = getIntProperty(context, properties, PROP_RETRY_COUNT, 3); + } + + private String getProperty(BundleContext context, Dictionary properties, String key) { + String fullKey = PID + "." + key; + // Check dictionary first + if (properties != null) { + Object value = properties.get(fullKey); + if (value == null) { + value = properties.get(key); + } + if (value != null) { + return substituteVars(context, value.toString()); + } + } + // Fall back to system/framework properties + String value = context.getProperty(fullKey); + if (value == null) { + value = System.getProperty(fullKey); + } + if (value != null) { + return substituteVars(context, value); + } + return null; + } + + private int getIntProperty(BundleContext context, Dictionary properties, String key, int defaultValue) { + String value = getProperty(context, properties, key); + if (value != null && !value.isEmpty()) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + // ignore + } + } + return defaultValue; + } + + private boolean getBooleanProperty(BundleContext context, Dictionary properties, String key, boolean defaultValue) { + String value = getProperty(context, properties, key); + if (value != null && !value.isEmpty()) { + return Boolean.parseBoolean(value.trim()); + } + return defaultValue; + } + + private List parseRepositories(BundleContext context, String repos) { + if (repos == null || repos.trim().isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (String repo : repos.split(",")) { + String trimmed = repo.trim(); + if (!trimmed.isEmpty()) { + trimmed = substituteVars(context, trimmed); + result.add(trimmed); + } + } + return result; + } + + private File resolveFile(BundleContext context, String path) { + path = substituteVars(context, path); + File file = new File(path); + if (!file.isAbsolute()) { + String karafHome = context.getProperty("karaf.home"); + if (karafHome != null) { + file = new File(karafHome, path); + } + } + return file; + } + + private String substituteVars(BundleContext context, String value) { + if (value == null) { + return null; + } + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < value.length()) { + if (value.charAt(i) == '$' && i + 1 < value.length() && value.charAt(i + 1) == '{') { + int end = value.indexOf('}', i + 2); + if (end > 0) { + String varName = value.substring(i + 2, end); + String varValue = context.getProperty(varName); + if (varValue == null) { + varValue = System.getProperty(varName); + } + if (varValue != null) { + result.append(varValue); + } else { + result.append(value, i, end + 1); + } + i = end + 1; + } else { + result.append(value.charAt(i)); + i++; + } + } else { + result.append(value.charAt(i)); + i++; + } + } + return result.toString(); + } + + public File getLocalRepository() { + return localRepository; + } + + public List getDefaultRepositories() { + return defaultRepositories; + } + + public List getRepositories() { + return repositories; + } + + public String getUpdatePolicy() { + return updatePolicy; + } + + public String getChecksumPolicy() { + return checksumPolicy; + } + + public int getConnectionTimeout() { + return connectionTimeout; + } + + public int getReadTimeout() { + return readTimeout; + } + + public boolean isCertificateCheck() { + return certificateCheck; + } + + public int getRetryCount() { + return retryCount; + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java new file mode 100644 index 00000000000..3beee4da226 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java @@ -0,0 +1,362 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.util.maven.Parser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of the {@link MavenResolver} service. + *

+ * Resolves Maven artifacts from local default repositories first, + * then falls back to remote repositories, downloading to the local repository. + */ +public class MavenResolverImpl implements MavenResolver { + + private static final Logger LOG = LoggerFactory.getLogger(MavenResolverImpl.class); + + private static final int BUFFER_SIZE = 8192; + + private final MavenConfiguration configuration; + + public MavenResolverImpl(MavenConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public File resolve(String url) throws IOException { + if (url == null) { + throw new IOException("URL cannot be null"); + } + String path = url; + if (path.startsWith("mvn:")) { + path = path.substring("mvn:".length()); + } + Parser parser = new Parser(path); + return resolve(parser); + } + + @Override + public File resolve(String groupId, String artifactId, String version, String type, String classifier) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append(groupId).append('/').append(artifactId); + if (version != null && !version.isEmpty()) { + sb.append('/').append(version); + } + if (type != null && !type.isEmpty()) { + sb.append('/').append(type); + } + if (classifier != null && !classifier.isEmpty()) { + if (type == null || type.isEmpty()) { + sb.append('/'); + } + sb.append('/').append(classifier); + } + Parser parser = new Parser(sb.toString()); + return resolve(parser); + } + + @Override + public File getLocalRepository() { + return configuration.getLocalRepository(); + } + + private File resolve(Parser parser) throws IOException { + String artifactPath = parser.getArtifactPath(); + + // 1. Check default repositories (local file-based) + for (String repo : configuration.getDefaultRepositories()) { + File file = resolveFromDefaultRepository(repo, artifactPath); + if (file != null) { + LOG.debug("Resolved {} from default repository", artifactPath); + return file; + } + } + + // 2. Check local repository + File localFile = new File(configuration.getLocalRepository(), artifactPath); + if (localFile.exists() && localFile.isFile()) { + LOG.debug("Resolved {} from local repository", artifactPath); + return localFile; + } + + // 3. Try remote repositories + for (String repo : configuration.getRepositories()) { + File file = resolveFromRemoteRepository(repo, artifactPath, localFile); + if (file != null) { + LOG.debug("Resolved {} from remote repository {}", artifactPath, repo); + return file; + } + } + + throw new IOException("Could not resolve artifact: mvn:" + parser.toMvnURI()); + } + + private File resolveFromDefaultRepository(String repo, String artifactPath) { + // Parse repository URL, stripping @id=... @snapshots @noreleases flags + String repoUrl = stripRepositoryFlags(repo); + + File repoDir; + if (repoUrl.startsWith("file:")) { + repoDir = new File(URI.create(repoUrl)); + } else { + repoDir = new File(repoUrl); + } + + File file = new File(repoDir, artifactPath); + if (file.exists() && file.isFile()) { + return file; + } + return null; + } + + private File resolveFromRemoteRepository(String repo, String artifactPath, File localFile) throws IOException { + String repoUrl = stripRepositoryFlags(repo); + + // Skip file-based repos that were already checked + if (repoUrl.startsWith("file:")) { + return null; + } + + // Build the full URL + String url = repoUrl; + if (!url.endsWith("/")) { + url += "/"; + } + url += artifactPath; + + int retryCount = configuration.getRetryCount(); + for (int attempt = 0; attempt <= retryCount; attempt++) { + try { + return downloadArtifact(url, localFile); + } catch (IOException e) { + if (attempt < retryCount) { + LOG.debug("Retry {}/{} for {}: {}", attempt + 1, retryCount, url, e.getMessage()); + } else { + LOG.debug("Failed to download {} after {} attempts: {}", url, retryCount + 1, e.getMessage()); + } + } + } + return null; + } + + private File downloadArtifact(String url, File localFile) throws IOException { + LOG.debug("Downloading {}", url); + + URLConnection connection = new URL(url).openConnection(); + if (connection instanceof HttpsURLConnection && !configuration.isCertificateCheck()) { + disableSslVerification((HttpsURLConnection) connection); + } + + connection.setConnectTimeout(configuration.getConnectionTimeout()); + connection.setReadTimeout(configuration.getReadTimeout()); + + if (connection instanceof HttpURLConnection) { + HttpURLConnection httpConnection = (HttpURLConnection) connection; + int responseCode = httpConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return null; + } + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new IOException("HTTP " + responseCode + " for " + url); + } + } + + // Download to a temp file, then move to the target location + File parentDir = localFile.getParentFile(); + if (!parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Could not create directory: " + parentDir); + } + + File tmpFile = new File(parentDir, localFile.getName() + ".tmp"); + try (InputStream in = connection.getInputStream(); + FileOutputStream out = new FileOutputStream(tmpFile)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + tmpFile.delete(); + throw e; + } + + // Verify checksum if available + if (connection instanceof HttpURLConnection) { + verifyChecksum(url, tmpFile); + } + + // Atomic rename + if (!tmpFile.renameTo(localFile)) { + // Fallback: copy and delete + tmpFile.delete(); + throw new IOException("Could not move downloaded file to " + localFile); + } + + return localFile; + } + + private void verifyChecksum(String url, File file) throws IOException { + String checksumPolicy = configuration.getChecksumPolicy(); + if ("ignore".equals(checksumPolicy)) { + return; + } + + // Try SHA-1 first, then MD5 + String remoteChecksum = fetchChecksum(url + ".sha1"); + if (remoteChecksum != null) { + String localChecksum = computeChecksum(file, "SHA-1"); + if (!remoteChecksum.equalsIgnoreCase(localChecksum)) { + String msg = "SHA-1 checksum mismatch for " + url; + if ("fail".equals(checksumPolicy)) { + file.delete(); + throw new IOException(msg); + } + LOG.warn(msg); + } + return; + } + + remoteChecksum = fetchChecksum(url + ".md5"); + if (remoteChecksum != null) { + String localChecksum = computeChecksum(file, "MD5"); + if (!remoteChecksum.equalsIgnoreCase(localChecksum)) { + String msg = "MD5 checksum mismatch for " + url; + if ("fail".equals(checksumPolicy)) { + file.delete(); + throw new IOException(msg); + } + LOG.warn(msg); + } + } + } + + private String fetchChecksum(String url) { + try { + URLConnection connection = new URL(url).openConnection(); + connection.setConnectTimeout(configuration.getConnectionTimeout()); + connection.setReadTimeout(configuration.getReadTimeout()); + if (connection instanceof HttpURLConnection) { + int code = ((HttpURLConnection) connection).getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + return null; + } + } + try (InputStream in = connection.getInputStream()) { + byte[] data = in.readAllBytes(); + String checksum = new String(data).trim(); + // Handle checksum files that contain filename after the hash + int space = checksum.indexOf(' '); + if (space > 0) { + checksum = checksum.substring(0, space); + } + return checksum; + } + } catch (IOException e) { + LOG.debug("Could not fetch checksum from {}: {}", url, e.getMessage()); + return null; + } + } + + private String computeChecksum(File file, String algorithm) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + try (InputStream in = new java.io.FileInputStream(file)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + byte[] hash = digest.digest(); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IOException("Algorithm not available: " + algorithm, e); + } + } + + private void disableSslVerification(HttpsURLConnection connection) { + try { + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + } + }; + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + connection.setSSLSocketFactory(sc.getSocketFactory()); + connection.setHostnameVerifier((hostname, session) -> true); + } catch (Exception e) { + LOG.warn("Could not disable SSL verification", e); + } + } + + static String stripRepositoryFlags(String repo) { + // Strip flags like @id=central @snapshots @noreleases @multi @update=... + String result = repo; + while (result.contains("@")) { + int atIdx = result.indexOf('@'); + int nextComma = result.indexOf(',', atIdx); + int nextSpace = result.indexOf(' ', atIdx); + int end; + if (nextComma >= 0 && nextSpace >= 0) { + end = Math.min(nextComma, nextSpace); + } else if (nextComma >= 0) { + end = nextComma; + } else if (nextSpace >= 0) { + end = nextSpace; + } else { + end = result.length(); + } + result = result.substring(0, atIdx) + result.substring(end); + } + return result.trim(); + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java new file mode 100644 index 00000000000..42f72abe1c1 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +import org.apache.karaf.services.url.MavenResolver; +import org.osgi.service.url.AbstractURLStreamHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OSGi URL stream handler for the {@code mvn:} protocol. + *

+ * Syntax: {@code mvn:[repository_url!]groupId/artifactId[/[version][/[type][/classifier]]]} + */ +public class MvnUrlHandler extends AbstractURLStreamHandlerService { + + private static final Logger LOG = LoggerFactory.getLogger(MvnUrlHandler.class); + + private final MavenResolver resolver; + + public MvnUrlHandler(MavenResolver resolver) { + this.resolver = resolver; + } + + @Override + public URLConnection openConnection(URL url) throws IOException { + LOG.debug("Opening connection for mvn: URL {}", url); + return new MvnConnection(url, resolver); + } + + static class MvnConnection extends URLConnection { + + private final MavenResolver resolver; + private File resolvedFile; + + MvnConnection(URL url, MavenResolver resolver) { + super(url); + this.resolver = resolver; + } + + @Override + public void connect() throws IOException { + if (resolvedFile == null) { + String mvnUrl = url.toExternalForm(); + resolvedFile = resolver.resolve(mvnUrl); + } + } + + @Override + public InputStream getInputStream() throws IOException { + connect(); + return new FileInputStream(resolvedFile); + } + + @Override + public long getContentLengthLong() { + try { + connect(); + return resolvedFile.length(); + } catch (IOException e) { + return -1; + } + } + + @Override + public String getContentType() { + return "application/octet-stream"; + } + + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java new file mode 100644 index 00000000000..4dfcb061964 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal.osgi; + +import java.util.Hashtable; + +import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.services.url.internal.MavenConfiguration; +import org.apache.karaf.services.url.internal.MavenResolverImpl; +import org.apache.karaf.services.url.internal.MvnUrlHandler; +import org.apache.karaf.util.tracker.BaseActivator; +import org.apache.karaf.util.tracker.annotation.Managed; +import org.apache.karaf.util.tracker.annotation.ProvideService; +import org.apache.karaf.util.tracker.annotation.Services; +import org.osgi.service.cm.ManagedService; +import org.osgi.service.url.URLStreamHandlerService; + +@Services( + provides = @ProvideService(MavenResolver.class) +) +@Managed(MavenConfiguration.PID) +public class Activator extends BaseActivator implements ManagedService { + + @Override + protected void doStart() throws Exception { + MavenConfiguration config = new MavenConfiguration(bundleContext, getConfiguration()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + register(MavenResolver.class, resolver); + + Hashtable props = new Hashtable<>(); + props.put("url.handler.protocol", "mvn"); + register(URLStreamHandlerService.class, new MvnUrlHandler(resolver), props); + } + +} diff --git a/services/url/src/main/resources/META-INF/LICENSE b/services/url/src/main/resources/META-INF/LICENSE new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/services/url/src/main/resources/META-INF/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/services/url/src/main/resources/META-INF/NOTICE b/services/url/src/main/resources/META-INF/NOTICE new file mode 100644 index 00000000000..8f0210b7740 --- /dev/null +++ b/services/url/src/main/resources/META-INF/NOTICE @@ -0,0 +1,9 @@ +Apache Karaf :: OSGi Services :: URL Handlers +Copyright 2007-2025 The Apache Software Foundation + +----------------------------------------------------------------------------- + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +----------------------------------------------------------------------------- diff --git a/services/url/src/main/resources/org.apache.karaf.url.mvn.cfg b/services/url/src/main/resources/org.apache.karaf.url.mvn.cfg new file mode 100644 index 00000000000..3cc0dadd78a --- /dev/null +++ b/services/url/src/main/resources/org.apache.karaf.url.mvn.cfg @@ -0,0 +1,84 @@ +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# +# Apache Karaf Maven URL Handler configuration +# +# This replaces the pax-url-mvn configuration with a native Karaf implementation. +# Property names are compatible with org.ops4j.pax.url.mvn.* for easier migration. +# + +# If set to true, SSL certificate validation is enforced when accessing +# Maven repositories through HTTPS +# +org.apache.karaf.url.mvn.certificateCheck = true + +# +# Path to the local Maven repository which is used to avoid downloading +# artifacts when they already exist locally. +# Defaults to: ${user.home}/.m2/repository +# +#org.apache.karaf.url.mvn.localRepository = + +# +# Comma separated list of default repositories (local, file-based). +# These are checked first, before the local repository and remote repositories. +# A repository URL can be appended with: +# @snapshots : the repository contains snapshots +# @noreleases : the repository does not contain releases +# @id=repo.id : the repository identifier +# +org.apache.karaf.url.mvn.defaultRepositories = \ + ${karaf.home.uri}${karaf.default.repository}@id=system.repository@snapshots, \ + ${karaf.data.uri}kar@id=kar.repository@multi@snapshots, \ + ${karaf.base.uri}${karaf.default.repository}@id=child.system.repository@snapshots + +# +# Comma separated list of remote repositories. +# These are accessed via HTTP/HTTPS when the artifact is not found locally. +# A repository URL can be appended with: +# @snapshots : the repository contains snapshots +# @noreleases : the repository does not contain releases +# @id=repo.id : the repository identifier +# +org.apache.karaf.url.mvn.repositories = \ + https://repo1.maven.org/maven2@id=central, \ + https://repository.apache.org/content/groups/snapshots-group@id=apache@snapshots@noreleases + +# +# Global update policy: always, daily, interval:NNN (minutes), never +# +#org.apache.karaf.url.mvn.globalUpdatePolicy = daily + +# +# Global checksum policy: warn, fail, ignore +# +#org.apache.karaf.url.mvn.globalChecksumPolicy = warn + +# +# Connection and socket timeouts +# +org.apache.karaf.url.mvn.timeout = 5000 +org.apache.karaf.url.mvn.socket.connectionTimeout = 5000 +org.apache.karaf.url.mvn.socket.readTimeout = 30000 + +# +# Number of connection retries after failure +# +org.apache.karaf.url.mvn.connection.retryCount = 3 diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenConfigurationTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenConfigurationTest.java new file mode 100644 index 00000000000..f1c3fac1d8f --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenConfigurationTest.java @@ -0,0 +1,290 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.util.Hashtable; + +import org.junit.Test; +import org.osgi.framework.BundleContext; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class MavenConfigurationTest { + + @Test + public void testDefaultConfiguration() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + MavenConfiguration config = new MavenConfiguration(context, null); + + assertEquals(new File(System.getProperty("user.home"), ".m2/repository"), config.getLocalRepository()); + assertTrue(config.getDefaultRepositories().isEmpty()); + assertTrue(config.getRepositories().isEmpty()); + assertNull(config.getUpdatePolicy()); + assertNull(config.getChecksumPolicy()); + assertEquals(5000, config.getConnectionTimeout()); + assertEquals(30000, config.getReadTimeout()); + assertTrue(config.isCertificateCheck()); + assertEquals(3, config.getRetryCount()); + + verify(context); + } + + @Test + public void testCustomLocalRepository() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("localRepository", "/tmp/test-repo"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(new File("/tmp/test-repo"), config.getLocalRepository()); + + verify(context); + } + + @Test + public void testFullyQualifiedPropertyKey() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("org.apache.karaf.url.mvn.localRepository", "/tmp/fq-repo"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(new File("/tmp/fq-repo"), config.getLocalRepository()); + + verify(context); + } + + @Test + public void testRepositoriesParsing() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("repositories", "https://repo1.maven.org/maven2@id=central, https://repo.apache.org@id=apache"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(2, config.getRepositories().size()); + assertEquals("https://repo1.maven.org/maven2@id=central", config.getRepositories().get(0)); + assertEquals("https://repo.apache.org@id=apache", config.getRepositories().get(1)); + + verify(context); + } + + @Test + public void testDefaultRepositoriesParsing() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("defaultRepositories", "/opt/karaf/system@id=system@snapshots, /opt/karaf/data/kar@id=kar"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(2, config.getDefaultRepositories().size()); + + verify(context); + } + + @Test + public void testTimeoutConfiguration() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("timeout", "10000"); + props.put("socket.connectionTimeout", "3000"); + props.put("socket.readTimeout", "60000"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(3000, config.getConnectionTimeout()); + assertEquals(60000, config.getReadTimeout()); + + verify(context); + } + + @Test + public void testTimeoutFallsBackToDefaultTimeout() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("timeout", "7000"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + // connectionTimeout should fall back to the "timeout" value + assertEquals(7000, config.getConnectionTimeout()); + + verify(context); + } + + @Test + public void testCertificateCheckDisabled() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("certificateCheck", "false"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertFalse(config.isCertificateCheck()); + + verify(context); + } + + @Test + public void testRetryCount() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("connection.retryCount", "5"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(5, config.getRetryCount()); + + verify(context); + } + + @Test + public void testPolicies() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("globalUpdatePolicy", "daily"); + props.put("globalChecksumPolicy", "fail"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals("daily", config.getUpdatePolicy()); + assertEquals("fail", config.getChecksumPolicy()); + + verify(context); + } + + @Test + public void testVariableSubstitution() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty("karaf.home")).andReturn("/opt/karaf").anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".localRepository")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".defaultRepositories")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".repositories")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".globalUpdatePolicy")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".globalChecksumPolicy")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".timeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".socket.connectionTimeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".socket.readTimeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".certificateCheck")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".connection.retryCount")).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("defaultRepositories", "${karaf.home}/system@id=system"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(1, config.getDefaultRepositories().size()); + assertEquals("/opt/karaf/system@id=system", config.getDefaultRepositories().get(0)); + + verify(context); + } + + @Test + public void testRelativeLocalRepositoryResolved() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty("karaf.home")).andReturn("/opt/karaf").anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".localRepository")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".defaultRepositories")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".repositories")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".globalUpdatePolicy")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".globalChecksumPolicy")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".timeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".socket.connectionTimeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".socket.readTimeout")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".certificateCheck")).andReturn(null).anyTimes(); + expect(context.getProperty(MavenConfiguration.PID + ".connection.retryCount")).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("localRepository", "data/repo"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(new File("/opt/karaf", "data/repo"), config.getLocalRepository()); + + verify(context); + } + + @Test + public void testInvalidIntPropertyUsesDefault() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("connection.retryCount", "notanumber"); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertEquals(3, config.getRetryCount()); + + verify(context); + } + + @Test + public void testEmptyRepositoriesString() { + BundleContext context = createMock(BundleContext.class); + expect(context.getProperty(anyString())).andReturn(null).anyTimes(); + replay(context); + + Hashtable props = new Hashtable<>(); + props.put("repositories", " "); + + MavenConfiguration config = new MavenConfiguration(context, props); + + assertTrue(config.getRepositories().isEmpty()); + + verify(context); + } + +} diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java new file mode 100644 index 00000000000..f8a0a987de8 --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java @@ -0,0 +1,319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class MavenResolverImplTest { + + private File tempDir; + private File localRepo; + + @Before + public void setUp() throws Exception { + tempDir = new File(System.getProperty("java.io.tmpdir"), "karaf-url-test-" + System.nanoTime()); + tempDir.mkdirs(); + localRepo = new File(tempDir, "local-repo"); + localRepo.mkdirs(); + } + + @After + public void tearDown() { + deleteRecursive(tempDir); + } + + @Test + public void testResolveFromLocalRepository() throws Exception { + // Create a fake artifact in the local repo + File artifactDir = new File(localRepo, "org/apache/karaf/test-artifact/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "test-artifact-1.0.0.jar"); + createFile(artifactFile, "fake-jar-content"); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:org.apache.karaf/test-artifact/1.0.0"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testResolveFromLocalRepositoryWithoutMvnPrefix() throws Exception { + File artifactDir = new File(localRepo, "org/apache/karaf/test-artifact/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "test-artifact-1.0.0.jar"); + createFile(artifactFile, "fake-jar-content"); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("org.apache.karaf/test-artifact/1.0.0"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + } + + @Test + public void testResolveFromDefaultRepository() throws Exception { + File defaultRepo = new File(tempDir, "system-repo"); + File artifactDir = new File(defaultRepo, "com/example/mylib/2.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "mylib-2.0.0.jar"); + createFile(artifactFile, "default-repo-content"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(defaultRepo.getAbsolutePath()), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/mylib/2.0.0"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testResolveFromDefaultRepositoryWithFlags() throws Exception { + File defaultRepo = new File(tempDir, "system-repo"); + File artifactDir = new File(defaultRepo, "com/example/mylib/2.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "mylib-2.0.0.jar"); + createFile(artifactFile, "default-repo-content"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(defaultRepo.getAbsolutePath() + "@id=system@snapshots"), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/mylib/2.0.0"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + } + + @Test + public void testResolveFromFileUriDefaultRepository() throws Exception { + File defaultRepo = new File(tempDir, "system-repo"); + File artifactDir = new File(defaultRepo, "com/example/mylib/2.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "mylib-2.0.0.jar"); + createFile(artifactFile, "file-uri-content"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(defaultRepo.toURI().toString() + "@id=system"), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/mylib/2.0.0"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + } + + @Test + public void testDefaultRepoCheckedBeforeLocalRepo() throws Exception { + // Create artifact in both default and local repos with different content + File defaultRepo = new File(tempDir, "default-repo"); + File defaultArtifactDir = new File(defaultRepo, "com/example/mylib/1.0.0"); + defaultArtifactDir.mkdirs(); + File defaultArtifact = new File(defaultArtifactDir, "mylib-1.0.0.jar"); + createFile(defaultArtifact, "from-default"); + + File localArtifactDir = new File(localRepo, "com/example/mylib/1.0.0"); + localArtifactDir.mkdirs(); + File localArtifact = new File(localArtifactDir, "mylib-1.0.0.jar"); + createFile(localArtifact, "from-local"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(defaultRepo.getAbsolutePath()), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/mylib/1.0.0"); + + // Default repository should be checked first + assertEquals(defaultArtifact.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test(expected = IOException.class) + public void testResolveNullUrlThrowsException() throws Exception { + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + resolver.resolve((String) null); + } + + @Test(expected = IOException.class) + public void testResolveNonExistentArtifactThrowsException() throws Exception { + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + resolver.resolve("mvn:com.nonexistent/artifact/1.0.0"); + } + + @Test + public void testResolveByCoordinates() throws Exception { + File artifactDir = new File(localRepo, "org/example/coords-test/3.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "coords-test-3.0.0.jar"); + createFile(artifactFile, "coords-content"); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("org.example", "coords-test", "3.0.0", null, null); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testResolveWithType() throws Exception { + File artifactDir = new File(localRepo, "org/example/typed/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "typed-1.0.0.xml"); + createFile(artifactFile, ""); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:org.example/typed/1.0.0/xml"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testResolveWithTypeAndClassifier() throws Exception { + File artifactDir = new File(localRepo, "org/example/classified/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "classified-1.0.0-features.xml"); + createFile(artifactFile, ""); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:org.example/classified/1.0.0/xml/features"); + + assertNotNull(resolved); + assertTrue(resolved.exists()); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testGetLocalRepository() { + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + assertEquals(localRepo, resolver.getLocalRepository()); + } + + @Test + public void testResolveMultipleDefaultRepositories() throws Exception { + File repo1 = new File(tempDir, "repo1"); + File repo2 = new File(tempDir, "repo2"); + + // Artifact only in second repo + File artifactDir = new File(repo2, "com/example/lib/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "lib-1.0.0.jar"); + createFile(artifactFile, "repo2-content"); + + MavenConfiguration config = createConfig( + localRepo, + Arrays.asList(repo1.getAbsolutePath(), repo2.getAbsolutePath()), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/lib/1.0.0"); + + assertNotNull(resolved); + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testStripRepositoryFlags() { + assertEquals("https://repo1.maven.org/maven2", + MavenResolverImpl.stripRepositoryFlags("https://repo1.maven.org/maven2@id=central")); + assertEquals("https://repo.apache.org", + MavenResolverImpl.stripRepositoryFlags("https://repo.apache.org@id=apache@snapshots@noreleases")); + assertEquals("/opt/karaf/system", + MavenResolverImpl.stripRepositoryFlags("/opt/karaf/system@id=system@snapshots")); + assertEquals("https://repo.example.com", + MavenResolverImpl.stripRepositoryFlags("https://repo.example.com")); + assertEquals("", + MavenResolverImpl.stripRepositoryFlags("@id=test")); + } + + // --- helpers --- + + private MavenConfiguration createConfig(File localRepo, java.util.List defaultRepos, java.util.List remoteRepos) { + MavenConfiguration config = createMock(MavenConfiguration.class); + expect(config.getLocalRepository()).andReturn(localRepo).anyTimes(); + expect(config.getDefaultRepositories()).andReturn(defaultRepos).anyTimes(); + expect(config.getRepositories()).andReturn(remoteRepos).anyTimes(); + expect(config.getChecksumPolicy()).andReturn("ignore").anyTimes(); + expect(config.getConnectionTimeout()).andReturn(5000).anyTimes(); + expect(config.getReadTimeout()).andReturn(30000).anyTimes(); + expect(config.isCertificateCheck()).andReturn(true).anyTimes(); + expect(config.getRetryCount()).andReturn(0).anyTimes(); + replay(config); + return config; + } + + private void createFile(File file, String content) throws IOException { + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(content.getBytes()); + } + } + + private void deleteRecursive(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursive(child); + } + } + } + file.delete(); + } + +} diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java new file mode 100644 index 00000000000..8f8ffbfcc53 --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +import org.apache.karaf.services.url.MavenResolver; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class MvnUrlHandlerTest { + + private File tempDir; + private File testFile; + + @Before + public void setUp() throws Exception { + tempDir = new File(System.getProperty("java.io.tmpdir"), "karaf-url-handler-test-" + System.nanoTime()); + tempDir.mkdirs(); + testFile = new File(tempDir, "test-artifact.jar"); + try (FileOutputStream fos = new FileOutputStream(testFile)) { + fos.write("test-jar-content".getBytes()); + } + } + + @After + public void tearDown() { + if (testFile != null) { + testFile.delete(); + } + if (tempDir != null) { + tempDir.delete(); + } + } + + @Test + public void testOpenConnection() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + replay(resolver); + + MvnUrlHandler handler = new MvnUrlHandler(resolver); + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + URLConnection connection = handler.openConnection(url); + + assertNotNull(connection); + + verify(resolver); + } + + @Test + public void testConnectionGetInputStream() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + expect(resolver.resolve(anyString())).andReturn(testFile); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + try (InputStream is = connection.getInputStream()) { + assertNotNull(is); + byte[] content = is.readAllBytes(); + assertEquals("test-jar-content", new String(content)); + } + + verify(resolver); + } + + @Test + public void testConnectionGetContentLengthLong() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + expect(resolver.resolve(anyString())).andReturn(testFile); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + assertEquals(testFile.length(), connection.getContentLengthLong()); + + verify(resolver); + } + + @Test + public void testConnectionGetContentLengthLongOnError() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + expect(resolver.resolve(anyString())).andThrow(new IOException("not found")); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + assertEquals(-1, connection.getContentLengthLong()); + + verify(resolver); + } + + @Test + public void testConnectionGetContentType() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + assertEquals("application/octet-stream", connection.getContentType()); + + verify(resolver); + } + + @Test + public void testConnectionConnectIsIdempotent() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + // resolve should only be called once even if connect is called multiple times + expect(resolver.resolve(anyString())).andReturn(testFile).once(); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + connection.connect(); + connection.connect(); + + verify(resolver); + } + + @Test(expected = IOException.class) + public void testConnectionGetInputStreamThrowsOnResolveFailure() throws Exception { + MavenResolver resolver = createMock(MavenResolver.class); + expect(resolver.resolve(anyString())).andThrow(new IOException("artifact not found")); + replay(resolver); + + URL url = new URL("http://localhost/mvn:org.example/test/1.0.0"); + MvnUrlHandler.MvnConnection connection = new MvnUrlHandler.MvnConnection(url, resolver); + + connection.getInputStream(); + } + +} From 4062903eec6343dcdc7cb6feaa69b8e138d29ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Tue, 8 Sep 2026 17:48:52 +0200 Subject: [PATCH 2/5] fix(services): fix bundle build failure in org.apache.karaf.services.url Remove the redundant explicit Bundle-Activator instruction, which collided with the activator class already detected by karaf-services-maven-plugin and produced an invalid duplicate-type manifest header, failing the bundle build. Also embed org.apache.karaf.util.maven and org.apache.karaf.util.tracker(.annotation), which are provided-scope dependencies that were not packaged into the bundle, leaving Parser and BaseActivator unresolved at runtime. --- services/url/pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/url/pom.xml b/services/url/pom.xml index de92c6b3580..a5a72980562 100644 --- a/services/url/pom.xml +++ b/services/url/pom.xml @@ -48,9 +48,6 @@ ${project.artifactId} - - org.apache.karaf.services.url.internal.osgi.Activator - The Apache Software Foundation org.osgi.framework;version="[1,3)", @@ -68,7 +65,10 @@ org.apache.karaf.services.url.internal, - org.apache.karaf.services.url.internal.osgi + org.apache.karaf.services.url.internal.osgi, + org.apache.karaf.util.maven, + org.apache.karaf.util.tracker, + org.apache.karaf.util.tracker.annotation org.osgi.service.url.URLStreamHandlerService, From 2233776049539a4facbd0dc7e752bc77ca68552e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Sat, 3 Oct 2026 04:35:45 +0200 Subject: [PATCH 3/5] feat(services): add wrap: protocol handler to the karaf.url service Add a wrap: URL handler to the native Karaf URL service, equivalent to pax-url-wrap. The handler supports the same syntax (wrap:jar-url[,instructions-url][$instructions]), the overwrite modes (keep, merge, full) and the same defaults (optional Import-Package, Export-Package: *, symbolic name derived from the URL). The OSGi manifest is generated with bnd, embedded privately in the bundle (only the used packages, with the OSGi API not provided by the framework), so all bnd instructions are supported. --- services/url/pom.xml | 28 ++- .../services/url/internal/BundleWrapper.java | 121 +++++++++++ .../services/url/internal/OverwriteMode.java | 50 +++++ .../services/url/internal/WrapUrlHandler.java | 93 ++++++++ .../services/url/internal/WrapUrlParser.java | 141 ++++++++++++ .../services/url/internal/osgi/Activator.java | 5 + .../url/src/main/resources/META-INF/NOTICE | 56 +++++ .../url/internal/BundleWrapperTest.java | 200 ++++++++++++++++++ .../url/internal/WrapUrlHandlerTest.java | 147 +++++++++++++ .../url/internal/WrapUrlParserTest.java | 137 ++++++++++++ 10 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/BundleWrapper.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/OverwriteMode.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlHandler.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlParser.java create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/BundleWrapperTest.java create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlHandlerTest.java create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlParserTest.java diff --git a/services/url/pom.xml b/services/url/pom.xml index a5a72980562..52ce08fb776 100644 --- a/services/url/pom.xml +++ b/services/url/pom.xml @@ -32,7 +32,7 @@ org.apache.karaf.services.url bundle Apache Karaf :: OSGi Services :: URL Handlers - Karaf URL Handlers Service (mvn: protocol) + Karaf URL Handlers Service (mvn: and wrap: protocols) @@ -68,8 +68,26 @@ org.apache.karaf.services.url.internal.osgi, org.apache.karaf.util.maven, org.apache.karaf.util.tracker, - org.apache.karaf.util.tracker.annotation + org.apache.karaf.util.tracker.annotation, + + aQute.bnd.annotation, + aQute.bnd.annotation.headers, + aQute.bnd.annotation.metatype, + aQute.bnd.annotation.plugin, + aQute.bnd.annotation.spi, + aQute.bnd.annotation.xml + + <_conditionalpackage> + aQute.*, + org.osgi.service.repository, + org.osgi.util.function, + org.osgi.util.promise + + <_bundleannotations> + !aQute.*, + * + org.osgi.service.url.URLStreamHandlerService, org.apache.karaf.services.url.MavenResolver @@ -115,6 +133,12 @@ org.apache.karaf.util provided + + biz.aQute.bnd + biz.aQute.bndlib + 7.4.0 + provided + org.slf4j slf4j-api diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/BundleWrapper.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/BundleWrapper.java new file mode 100644 index 00000000000..1f26b7b454f --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/BundleWrapper.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.jar.Manifest; + +import aQute.bnd.osgi.Analyzer; +import aQute.bnd.osgi.Constants; +import aQute.bnd.osgi.Jar; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Turns a plain jar into an OSGi bundle, generating the OSGi manifest headers with bnd. + */ +public final class BundleWrapper { + + private static final Logger LOG = LoggerFactory.getLogger(BundleWrapper.class); + + private BundleWrapper() { + } + + /** + * Generate the OSGi headers of a jar. + * + * @param jarStream the jar to wrap + * @param instructions the bnd instructions + * @param jarInfo the origin of the jar (usually its URL), used as default symbolic name + * @param overwriteMode what to do when the jar is already a bundle + * @return the wrapped jar + * @throws IOException if the jar cannot be read or wrapped + */ + public static InputStream wrap(InputStream jarStream, Properties instructions, String jarInfo, + OverwriteMode overwriteMode) throws IOException { + LOG.debug("Wrapping {} (overwrite mode {}) using instructions {}", jarInfo, overwriteMode, instructions); + try (Jar jar = new Jar("dot", jarStream)) { + Manifest manifest = jar.getManifest(); + if (manifest == null && jar.getResources().isEmpty()) { + throw new IOException(jarInfo + " is not a jar or is empty"); + } + if (manifest != null && overwriteMode == OverwriteMode.KEEP && isBundle(manifest)) { + return write(jar); + } + // the analyzer owns the jar, so it has to stay open until the jar is written + try (Analyzer analyzer = new Analyzer()) { + analyzer.setJar(jar); + analyzer.setProperties(instructions); + if (manifest != null && overwriteMode == OverwriteMode.MERGE) { + analyzer.mergeManifest(manifest); + } + setDefaults(analyzer, jarInfo); + jar.setManifest(analyzer.calcManifest()); + if (!analyzer.getErrors().isEmpty() || !analyzer.getWarnings().isEmpty()) { + LOG.debug("Wrapping {} reported errors {} and warnings {}", + jarInfo, analyzer.getErrors(), analyzer.getWarnings()); + } + return write(jar); + } + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Unable to wrap " + jarInfo, e); + } + } + + private static boolean isBundle(Manifest manifest) { + return manifest.getMainAttributes().getValue(Constants.EXPORT_PACKAGE) != null + || manifest.getMainAttributes().getValue(Constants.IMPORT_PACKAGE) != null; + } + + private static void setDefaults(Analyzer analyzer, String jarInfo) { + if (isEmpty(analyzer.getProperty(Constants.IMPORT_PACKAGE))) { + analyzer.setProperty(Constants.IMPORT_PACKAGE, "*;resolution:=optional"); + } + if (isEmpty(analyzer.getProperty(Constants.EXPORT_PACKAGE))) { + analyzer.setProperty(Constants.EXPORT_PACKAGE, "*"); + } + analyzer.setProperty(Constants.BUNDLE_SYMBOLICNAME, + toSymbolicName(analyzer.getProperty(Constants.BUNDLE_SYMBOLICNAME, jarInfo))); + } + + // replace the characters not allowed in a symbolic name, leaving the directives untouched + static String toSymbolicName(String value) { + int parameters = value.indexOf(';'); + String name = parameters < 0 ? value : value.substring(0, parameters); + String sanitized = name.trim().replaceAll("[^a-zA-Z_0-9.-]", "_"); + return parameters < 0 ? sanitized : sanitized + value.substring(parameters); + } + + private static boolean isEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + + private static InputStream write(Jar jar) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + jar.write(out); + return new ByteArrayInputStream(out.toByteArray()); + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/OverwriteMode.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/OverwriteMode.java new file mode 100644 index 00000000000..3ade7711b92 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/OverwriteMode.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +/** + * Strategy applied by the {@code wrap:} protocol when the wrapped jar is already a bundle. + */ +public enum OverwriteMode { + + /** Keep the existing manifest. */ + KEEP, + + /** Merge the wrapping instructions with the existing manifest headers. */ + MERGE, + + /** Ignore the existing manifest and generate a new one. */ + FULL; + + /** + * @param value the value of the {@code overwrite} instruction (can be {@code null}) + * @return the corresponding mode, {@link #KEEP} if the value is missing or unknown + */ + public static OverwriteMode parse(String value) { + if (value != null) { + try { + return valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + // fall back to the default mode + } + } + return KEEP; + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlHandler.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlHandler.java new file mode 100644 index 00000000000..27104bdd97e --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlHandler.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Properties; + +import org.osgi.service.url.AbstractURLStreamHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OSGi URL stream handler for the {@code wrap:} protocol, turning a plain jar into an OSGi bundle on the fly. + *

+ * Syntax: {@code wrap:wrapped-jar-url[,wrapping-instructions-url][$wrapping-instructions]} + */ +public class WrapUrlHandler extends AbstractURLStreamHandlerService { + + private static final Logger LOG = LoggerFactory.getLogger(WrapUrlHandler.class); + + private static final String PROTOCOL = "wrap:"; + + private static final String OVERWRITE = "overwrite"; + + @Override + public URLConnection openConnection(URL url) throws IOException { + LOG.debug("Opening connection for wrap: URL {}", url); + return new WrapConnection(url); + } + + static class WrapConnection extends URLConnection { + + private final WrapUrlParser parser; + + WrapConnection(URL url) throws MalformedURLException { + super(url); + // the wrapped URL and the instructions can contain ? and #, so the path is not enough + String path = url.toExternalForm(); + if (path.startsWith(PROTOCOL)) { + path = path.substring(PROTOCOL.length()); + } + this.parser = new WrapUrlParser(path); + } + + @Override + public void connect() { + // nothing to do, the wrapped jar is read when the stream is requested + } + + @Override + public InputStream getInputStream() throws IOException { + Properties instructions = new Properties(); + if (parser.getInstructionsUrl() != null) { + try (InputStream is = parser.getInstructionsUrl().openStream()) { + instructions.load(is); + } + } + // the instructions provided in the URL win over the ones from the instructions file + instructions.putAll(parser.getInstructions()); + OverwriteMode overwriteMode = OverwriteMode.parse((String) instructions.remove(OVERWRITE)); + try (InputStream is = parser.getWrappedJarUrl().openStream()) { + return BundleWrapper.wrap(is, instructions, url.toExternalForm(), overwriteMode); + } + } + + @Override + public String getContentType() { + return "application/octet-stream"; + } + + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlParser.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlParser.java new file mode 100644 index 00000000000..568d4a7d817 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/WrapUrlParser.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import aQute.bnd.osgi.Constants; + +/** + * Parser for the {@code wrap:} protocol. + *

+ * Syntax: {@code wrap:wrapped-jar-url[,wrapping-instructions-url][$wrapping-instructions]} + * where the wrapping instructions are {@code &} separated {@code header=value} bnd instructions. + */ +public class WrapUrlParser { + + private static final String SYNTAX = "wrap:wrapped-jar-url[,wrapping-instructions-url][$wrapping-instructions]"; + + private static final String INSTRUCTIONS_SEPARATOR = "$"; + + private static final String WEB_CONTEXT_PATH = "Web-ContextPath"; + + private static final Pattern JAR_FILE_INSTRUCTIONS = Pattern.compile("(.+?),(.+?)\\$(.+?)"); + private static final Pattern JAR_INSTRUCTIONS = Pattern.compile("(.+?)\\$(.+?)"); + private static final Pattern JAR_FILE = Pattern.compile("(.+?),(.+?)"); + + private static final Pattern INSTRUCTION = + Pattern.compile("([a-zA-Z_0-9-]+)=([\\-!\"'()\\[\\]*+,.\\\\0-9A-Z_a-z%;:=/\\s]+)?"); + + private final URL wrappedJarUrl; + private final URL instructionsUrl; + private final Properties instructions; + + /** + * @param path the {@code wrap:} URL, without the protocol + * @throws MalformedURLException if the path does not comply with the {@code wrap:} syntax + */ + public WrapUrlParser(String path) throws MalformedURLException { + if (path == null || path.trim().isEmpty()) { + throw new MalformedURLException("Path cannot be null or empty. Syntax " + SYNTAX); + } + if (path.startsWith(INSTRUCTIONS_SEPARATOR) || path.endsWith(INSTRUCTIONS_SEPARATOR)) { + throw new MalformedURLException("Path cannot start or end with " + INSTRUCTIONS_SEPARATOR + ". Syntax " + SYNTAX); + } + Matcher matcher = JAR_FILE_INSTRUCTIONS.matcher(path); + if (matcher.matches()) { + wrappedJarUrl = new URL(matcher.group(1)); + instructionsUrl = new URL(matcher.group(2)); + instructions = parseInstructions(matcher.group(3)); + } else if ((matcher = JAR_INSTRUCTIONS.matcher(path)).matches()) { + wrappedJarUrl = new URL(matcher.group(1)); + instructionsUrl = null; + instructions = parseInstructions(matcher.group(2)); + } else if ((matcher = JAR_FILE.matcher(path)).matches()) { + wrappedJarUrl = new URL(matcher.group(1)); + instructionsUrl = new URL(matcher.group(2)); + instructions = new Properties(); + } else { + wrappedJarUrl = new URL(path); + instructionsUrl = null; + instructions = new Properties(); + } + } + + public URL getWrappedJarUrl() { + return wrappedJarUrl; + } + + /** + * @return the URL of the properties file providing wrapping instructions, or {@code null} + */ + public URL getInstructionsUrl() { + return instructionsUrl; + } + + /** + * @return the wrapping instructions provided inline in the URL + */ + public Properties getInstructions() { + return instructions; + } + + static Properties parseInstructions(String query) throws MalformedURLException { + Properties instructions = new Properties(); + for (String segment : query.split("&")) { + if (segment.trim().isEmpty()) { + continue; + } + Matcher matcher = INSTRUCTION.matcher(segment); + if (!matcher.matches()) { + throw new MalformedURLException("Invalid syntax for instruction [" + segment + "]. Syntax " + SYNTAX); + } + String value = matcher.group(2); + try { + instructions.setProperty(normalizeHeader(matcher.group(1)), + value != null ? URLDecoder.decode(value, StandardCharsets.UTF_8) : ""); + } catch (IllegalArgumentException e) { + MalformedURLException exception = new MalformedURLException("Invalid encoding in instruction [" + segment + "]"); + exception.initCause(e); + throw exception; + } + } + return instructions; + } + + // headers are case sensitive in bnd, so bundle-version is turned into Bundle-Version + private static String normalizeHeader(String key) { + if (WEB_CONTEXT_PATH.equalsIgnoreCase(key)) { + return WEB_CONTEXT_PATH; + } + for (String header : Constants.headers) { + if (header.equalsIgnoreCase(key)) { + return header; + } + } + return key; + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java index 4dfcb061964..9e2132ec4c4 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java @@ -24,6 +24,7 @@ import org.apache.karaf.services.url.internal.MavenConfiguration; import org.apache.karaf.services.url.internal.MavenResolverImpl; import org.apache.karaf.services.url.internal.MvnUrlHandler; +import org.apache.karaf.services.url.internal.WrapUrlHandler; import org.apache.karaf.util.tracker.BaseActivator; import org.apache.karaf.util.tracker.annotation.Managed; import org.apache.karaf.util.tracker.annotation.ProvideService; @@ -47,6 +48,10 @@ protected void doStart() throws Exception { Hashtable props = new Hashtable<>(); props.put("url.handler.protocol", "mvn"); register(URLStreamHandlerService.class, new MvnUrlHandler(resolver), props); + + props = new Hashtable<>(); + props.put("url.handler.protocol", "wrap"); + register(URLStreamHandlerService.class, new WrapUrlHandler(), props); } } diff --git a/services/url/src/main/resources/META-INF/NOTICE b/services/url/src/main/resources/META-INF/NOTICE index 8f0210b7740..fa43b2ebf7a 100644 --- a/services/url/src/main/resources/META-INF/NOTICE +++ b/services/url/src/main/resources/META-INF/NOTICE @@ -7,3 +7,59 @@ This product includes software developed at The Apache Software Foundation (http://www.apache.org/). ----------------------------------------------------------------------------- + +This product bundles bnd (https://bnd.bndtools.org/), which is available under +the Apache License, Version 2.0: +| Copyright (c) aQute SARL (2000, 2026) and others. All Rights Reserved. + +----------------------------------------------------------------------------- + +This product bundles OSGi with the following in its NOTICE: +| # Notices for osgi +| +| This content is produced and maintained by the OSGi Specification Project. +| +| * Project home: https://projects.eclipse.org/projects/technology.osgi +| +| ## Trademarks +| +| OSGi and the OSGi Logo are trademarks of the Eclipse Foundation. Eclipse and +| the Eclipse Logo are registered trademarks of the Eclipse Foundation. +| +| ## Copyright +| +| All content is the property of the respective authors or their employers. +| For more information regarding authorship of content, please consult the +| listed source code repository logs. +| +| ## Declared Project Licenses +| +| This program and the accompanying materials are made available under the terms +| of the Apache License, Version 2.0 which is available at +| http://www.apache.org/licenses/LICENSE-2.0 +| +| SPDX-License-Identifier: Apache-2.0 +| +| ## Source Code +| +| The project maintains the following source code repositories: +| +| * https://github.com/osgi/osgi.git +| +| ## Third-party Content +| +| The Content may include items that have been sourced from third parties as follows:

+| +| JUnit +| +| * License: CPL-1.0 (JUnit 3), EPL-1.0 (JUnit 4), EPL-2.0 (JUnit 5) +| * Project: https://junit.org/ +| * Source: https://github.com/junit-team +| +| Apache Software Foundation +| +| * License: Apache-2.0 +| * Project: https://apache.org/ +| * Source: https://github.com/apache/ + +----------------------------------------------------------------------------- diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/BundleWrapperTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/BundleWrapperTest.java new file mode 100644 index 00000000000..92d2dd3073a --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/BundleWrapperTest.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class BundleWrapperTest { + + private static final String JAR_INFO = "wrap:mvn:org.example/test/1.0.0"; + + private static final String CLASS_RESOURCE = OverwriteMode.class.getName().replace('.', '/') + ".class"; + + private static final String CLASS_PACKAGE = OverwriteMode.class.getPackage().getName(); + + @Test + public void testWrapPlainJar() throws Exception { + Attributes headers = wrap(createJar(new Manifest()), new Properties(), OverwriteMode.KEEP); + + assertEquals("2", headers.getValue("Bundle-ManifestVersion")); + assertEquals("wrap_mvn_org.example_test_1.0.0", headers.getValue("Bundle-SymbolicName")); + assertTrue(headers.getValue("Export-Package").contains(CLASS_PACKAGE)); + } + + @Test + public void testWrapJarWithoutManifest() throws Exception { + Attributes headers = wrap(createJar(null), new Properties(), OverwriteMode.KEEP); + + assertEquals("wrap_mvn_org.example_test_1.0.0", headers.getValue("Bundle-SymbolicName")); + assertTrue(headers.getValue("Export-Package").contains(CLASS_PACKAGE)); + } + + @Test + public void testImportsAreOptionalByDefault() throws Exception { + Properties instructions = new Properties(); + // do not export the package, so that the imports only contain the referred packages + instructions.setProperty("Export-Package", "!*"); + instructions.setProperty("Private-Package", "*"); + byte[] jar = createJar(new Manifest(), BundleWrapperTest.class); + + Attributes headers = wrap(jar, instructions, OverwriteMode.KEEP); + + assertNull(headers.getValue("Export-Package")); + assertTrue(headers.getValue("Import-Package"), headers.getValue("Import-Package").contains("org.junit;resolution:=optional")); + } + + @Test + public void testWrapWithInstructions() throws Exception { + Properties instructions = new Properties(); + instructions.setProperty("Bundle-SymbolicName", "org.example.test;singleton:=true"); + instructions.setProperty("Bundle-Version", "1.2.3"); + instructions.setProperty("Export-Package", CLASS_PACKAGE + ";version=\"4.5.6\""); + instructions.setProperty("Custom-Header", "custom"); + + Attributes headers = wrap(createJar(new Manifest()), instructions, OverwriteMode.KEEP); + + assertEquals("org.example.test;singleton:=true", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + assertEquals(CLASS_PACKAGE + ";version=\"4.5.6\"", headers.getValue("Export-Package")); + assertEquals("custom", headers.getValue("Custom-Header")); + } + + @Test + public void testKeepExistingBundle() throws Exception { + Properties instructions = new Properties(); + instructions.setProperty("Bundle-SymbolicName", "org.example.wrapped"); + instructions.setProperty("Bundle-Version", "1.2.3"); + + Attributes headers = wrap(createJar(bundleManifest()), instructions, OverwriteMode.KEEP); + + assertEquals("org.example.existing", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.0.0", headers.getValue("Bundle-Version")); + assertEquals("org.example.existing.api", headers.getValue("Export-Package")); + } + + @Test + public void testMergeExistingBundle() throws Exception { + Properties instructions = new Properties(); + instructions.setProperty("Bundle-Version", "1.2.3"); + + Attributes headers = wrap(createJar(bundleManifest()), instructions, OverwriteMode.MERGE); + + assertEquals("org.example.existing", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + } + + @Test + public void testFullOverwriteExistingBundle() throws Exception { + Properties instructions = new Properties(); + instructions.setProperty("Bundle-Version", "1.2.3"); + + Attributes headers = wrap(createJar(bundleManifest()), instructions, OverwriteMode.FULL); + + assertEquals("wrap_mvn_org.example_test_1.0.0", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + assertTrue(headers.getValue("Export-Package").contains(CLASS_PACKAGE)); + } + + @Test + public void testWrappedJarKeepsContent() throws Exception { + byte[] jar = createJar(new Manifest()); + + try (JarInputStream wrapped = new JarInputStream(BundleWrapper.wrap(new ByteArrayInputStream(jar), + new Properties(), JAR_INFO, OverwriteMode.KEEP))) { + List files = new ArrayList<>(); + for (JarEntry entry; (entry = wrapped.getNextJarEntry()) != null;) { + if (!entry.isDirectory()) { + files.add(entry.getName()); + assertArrayEquals(readClass(OverwriteMode.class), wrapped.readAllBytes()); + } + } + assertEquals(Collections.singletonList(CLASS_RESOURCE), files); + } + } + + @Test(expected = IOException.class) + public void testWrapInvalidJar() throws Exception { + BundleWrapper.wrap(new ByteArrayInputStream("not-a-jar".getBytes()), new Properties(), JAR_INFO, OverwriteMode.KEEP); + } + + @Test + public void testToSymbolicName() { + assertEquals("wrap_file__repo_test-1.0.jar", BundleWrapper.toSymbolicName("wrap:file:/repo/test-1.0.jar")); + assertEquals("org.example.test", BundleWrapper.toSymbolicName("org.example.test")); + assertEquals("org.example_test;singleton:=true", BundleWrapper.toSymbolicName("org.example test;singleton:=true")); + } + + private static Attributes wrap(byte[] jar, Properties instructions, OverwriteMode overwriteMode) throws IOException { + try (JarInputStream wrapped = new JarInputStream(BundleWrapper.wrap(new ByteArrayInputStream(jar), + instructions, JAR_INFO, overwriteMode))) { + assertNotNull(wrapped.getManifest()); + return wrapped.getManifest().getMainAttributes(); + } + } + + private static Manifest bundleManifest() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Bundle-ManifestVersion", "2"); + manifest.getMainAttributes().putValue("Bundle-SymbolicName", "org.example.existing"); + manifest.getMainAttributes().putValue("Bundle-Version", "1.0.0"); + manifest.getMainAttributes().putValue("Export-Package", "org.example.existing.api"); + return manifest; + } + + static byte[] createJar(Manifest manifest) throws IOException { + return createJar(manifest, OverwriteMode.class); + } + + private static byte[] createJar(Manifest manifest, Class clazz) throws IOException { + if (manifest != null) { + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JarOutputStream jar = manifest != null ? new JarOutputStream(out, manifest) : new JarOutputStream(out)) { + jar.putNextEntry(new JarEntry(clazz.getName().replace('.', '/') + ".class")); + jar.write(readClass(clazz)); + jar.closeEntry(); + } + return out.toByteArray(); + } + + private static byte[] readClass(Class clazz) throws IOException { + try (InputStream is = clazz.getResourceAsStream(clazz.getSimpleName() + ".class")) { + return is.readAllBytes(); + } + } + +} diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlHandlerTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlHandlerTest.java new file mode 100644 index 00000000000..b044c7a3af4 --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlHandlerTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.jar.Attributes; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.junit.Assert.*; + +public class WrapUrlHandlerTest { + + @Rule + public TemporaryFolder tempDir = new TemporaryFolder(); + + private final WrapUrlHandler handler = new WrapUrlHandler(); + + // outside of OSGi, the wrap: protocol is not registered and the handler service can not parse the URL itself + private final URLStreamHandler protocol = new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) throws IOException { + return handler.openConnection(url); + } + }; + + private String jarUrl; + + @Before + public void setUp() throws Exception { + File jar = tempDir.newFile("test.jar"); + Files.write(jar.toPath(), BundleWrapperTest.createJar(new Manifest())); + jarUrl = jar.toURI().toURL().toExternalForm(); + } + + @Test + public void testWrap() throws Exception { + Attributes headers = open("wrap:" + jarUrl); + + assertEquals(("wrap:" + jarUrl).replaceAll("[^a-zA-Z_0-9.-]", "_"), headers.getValue("Bundle-SymbolicName")); + assertNotNull(headers.getValue("Export-Package")); + } + + @Test + public void testWrapWithInstructions() throws Exception { + Attributes headers = open("wrap:" + jarUrl + "$Bundle-SymbolicName=org.example.test&Bundle-Version=1.2.3"); + + assertEquals("org.example.test", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + } + + @Test + public void testWrapWithInstructionsFile() throws Exception { + String instructionsUrl = createInstructionsFile("Bundle-SymbolicName=org.example.file", "Bundle-Version=1.2.3"); + + Attributes headers = open("wrap:" + jarUrl + "," + instructionsUrl); + + assertEquals("org.example.file", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + } + + @Test + public void testUrlInstructionsOverrideInstructionsFile() throws Exception { + String instructionsUrl = createInstructionsFile("Bundle-SymbolicName=org.example.file", "Bundle-Version=1.2.3"); + + Attributes headers = open("wrap:" + jarUrl + "," + instructionsUrl + "$Bundle-SymbolicName=org.example.url"); + + assertEquals("org.example.url", headers.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", headers.getValue("Bundle-Version")); + } + + @Test + public void testOverwriteMode() throws Exception { + File bundle = tempDir.newFile("bundle.jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Bundle-SymbolicName", "org.example.existing"); + manifest.getMainAttributes().putValue("Bundle-Version", "1.0.0"); + manifest.getMainAttributes().putValue("Import-Package", "org.example.other"); + Files.write(bundle.toPath(), BundleWrapperTest.createJar(manifest)); + String bundleUrl = bundle.toURI().toURL().toExternalForm(); + + Attributes kept = open("wrap:" + bundleUrl + "$Bundle-Version=1.2.3"); + assertEquals("org.example.existing", kept.getValue("Bundle-SymbolicName")); + assertEquals("1.0.0", kept.getValue("Bundle-Version")); + + Attributes merged = open("wrap:" + bundleUrl + "$Bundle-Version=1.2.3&overwrite=merge"); + assertEquals("org.example.existing", merged.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", merged.getValue("Bundle-Version")); + assertNull(merged.getValue("overwrite")); + + Attributes full = open("wrap:" + bundleUrl + "," + createInstructionsFile("overwrite=FULL") + "$Bundle-Version=1.2.3"); + assertNotEquals("org.example.existing", full.getValue("Bundle-SymbolicName")); + assertEquals("1.2.3", full.getValue("Bundle-Version")); + } + + @Test(expected = MalformedURLException.class) + public void testOpenConnectionWithInvalidUrl() throws Exception { + handler.openConnection(new URL(null, "wrap:" + jarUrl + "$Bundle-SymbolicName", protocol)); + } + + @Test(expected = IOException.class) + public void testWrapMissingJar() throws Exception { + open("wrap:" + new File(tempDir.getRoot(), "missing.jar").toURI().toURL()); + } + + private Attributes open(String url) throws IOException { + try (JarInputStream jar = new JarInputStream(new URL(null, url, protocol).openStream())) { + assertNotNull(jar.getManifest()); + return jar.getManifest().getMainAttributes(); + } + } + + private String createInstructionsFile(String... instructions) throws IOException { + File file = tempDir.newFile(); + Files.write(file.toPath(), Arrays.asList(instructions)); + return file.toURI().toURL().toExternalForm(); + } + +} diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlParserTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlParserTest.java new file mode 100644 index 00000000000..5e0c9311c99 --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/WrapUrlParserTest.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.net.MalformedURLException; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class WrapUrlParserTest { + + @Test + public void testJarOnly() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar"); + + assertEquals("file:/repo/test.jar", parser.getWrappedJarUrl().toExternalForm()); + assertNull(parser.getInstructionsUrl()); + assertTrue(parser.getInstructions().isEmpty()); + } + + @Test + public void testJarWithInstructions() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar$Bundle-SymbolicName=test&Bundle-Version=1.2.3"); + + assertEquals("file:/repo/test.jar", parser.getWrappedJarUrl().toExternalForm()); + assertNull(parser.getInstructionsUrl()); + assertEquals(2, parser.getInstructions().size()); + assertEquals("test", parser.getInstructions().getProperty("Bundle-SymbolicName")); + assertEquals("1.2.3", parser.getInstructions().getProperty("Bundle-Version")); + } + + @Test + public void testJarWithInstructionsFile() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar,file:/repo/test.bnd"); + + assertEquals("file:/repo/test.jar", parser.getWrappedJarUrl().toExternalForm()); + assertEquals("file:/repo/test.bnd", parser.getInstructionsUrl().toExternalForm()); + assertTrue(parser.getInstructions().isEmpty()); + } + + @Test + public void testJarWithInstructionsFileAndInstructions() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar,file:/repo/test.bnd$overwrite=merge"); + + assertEquals("file:/repo/test.jar", parser.getWrappedJarUrl().toExternalForm()); + assertEquals("file:/repo/test.bnd", parser.getInstructionsUrl().toExternalForm()); + assertEquals("merge", parser.getInstructions().getProperty("overwrite")); + } + + @Test + public void testInstructionWithPackageList() throws Exception { + WrapUrlParser parser = new WrapUrlParser( + "file:/repo/test.jar$Export-Package=org.example.*;version=\"1.0\",org.other&Import-Package=*;resolution:=optional"); + + assertEquals("file:/repo/test.jar", parser.getWrappedJarUrl().toExternalForm()); + assertNull(parser.getInstructionsUrl()); + assertEquals("org.example.*;version=\"1.0\",org.other", parser.getInstructions().getProperty("Export-Package")); + assertEquals("*;resolution:=optional", parser.getInstructions().getProperty("Import-Package")); + } + + @Test + public void testInstructionValueIsDecoded() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar$Bundle-Name=My%20Bundle%26Co"); + + assertEquals("My Bundle&Co", parser.getInstructions().getProperty("Bundle-Name")); + } + + @Test + public void testInstructionHeaderIsNormalized() throws Exception { + WrapUrlParser parser = new WrapUrlParser( + "file:/repo/test.jar$bundle-symbolicname=test&web-contextpath=/test&Custom-Header=value"); + + assertEquals("test", parser.getInstructions().getProperty("Bundle-SymbolicName")); + assertEquals("/test", parser.getInstructions().getProperty("Web-ContextPath")); + assertEquals("value", parser.getInstructions().getProperty("Custom-Header")); + } + + @Test + public void testInstructionWithoutValue() throws Exception { + WrapUrlParser parser = new WrapUrlParser("file:/repo/test.jar$Import-Package="); + + assertEquals("", parser.getInstructions().getProperty("Import-Package")); + } + + @Test(expected = MalformedURLException.class) + public void testNullPath() throws Exception { + new WrapUrlParser(null); + } + + @Test(expected = MalformedURLException.class) + public void testEmptyPath() throws Exception { + new WrapUrlParser(" "); + } + + @Test(expected = MalformedURLException.class) + public void testPathStartingWithInstructionsSeparator() throws Exception { + new WrapUrlParser("$Bundle-SymbolicName=test"); + } + + @Test(expected = MalformedURLException.class) + public void testPathEndingWithInstructionsSeparator() throws Exception { + new WrapUrlParser("file:/repo/test.jar$"); + } + + @Test(expected = MalformedURLException.class) + public void testInvalidWrappedJarUrl() throws Exception { + new WrapUrlParser("not-a-url"); + } + + @Test(expected = MalformedURLException.class) + public void testInvalidInstruction() throws Exception { + new WrapUrlParser("file:/repo/test.jar$Bundle-SymbolicName"); + } + + @Test(expected = MalformedURLException.class) + public void testInvalidInstructionEncoding() throws Exception { + new WrapUrlParser("file:/repo/test.jar$Bundle-Name=100%"); + } + +} From 8cac3f8086c598c7c5bf77bbbe837a25a61c744d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Mon, 19 Oct 2026 18:22:41 +0200 Subject: [PATCH 4/5] feat(services): implement the Karaf Maven resolver SPI in the karaf.url service The features service now resolves mvn: URIs through the org.apache.karaf.features.spi contract, so the karaf.url service implements it instead of exposing its own MavenResolver API. MavenResolverImpl implements org.apache.karaf.features.spi.MavenResolver. A failed resolution is never retried by the features service, as the downloads from the remote repositories are already retried (connection.retryCount). The bundle registers a MavenResolverFactory, which the features service uses with the org.apache.karaf.url.mvn configuration, and a ready to use MavenResolver, used by the components that just resolve an artifact (feature:export-bundles). The factory is also declared for ServiceLoader, so it can be used outside the OSGi framework, where the configuration variables are resolved with the system properties. The org.apache.karaf.services.url package is removed: the bundle no longer exports any package. --- services/url/pom.xml | 13 ++- .../karaf/services/url/MavenResolver.java | 61 -------------- .../internal/KarafMavenResolverFactory.java | 57 +++++++++++++ .../url/internal/MavenConfiguration.java | 35 +++++--- .../url/internal/MavenResolverImpl.java | 15 ++-- .../services/url/internal/MvnUrlHandler.java | 2 +- .../services/url/internal/osgi/Activator.java | 15 +++- ...he.karaf.features.spi.MavenResolverFactory | 17 ++++ .../KarafMavenResolverFactoryTest.java | 83 +++++++++++++++++++ .../url/internal/MavenResolverImplTest.java | 22 ++++- .../url/internal/MvnUrlHandlerTest.java | 2 +- 11 files changed, 232 insertions(+), 90 deletions(-) delete mode 100644 services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java create mode 100644 services/url/src/main/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactory.java create mode 100644 services/url/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory create mode 100644 services/url/src/test/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactoryTest.java diff --git a/services/url/pom.xml b/services/url/pom.xml index 52ce08fb776..f48f55ba669 100644 --- a/services/url/pom.xml +++ b/services/url/pom.xml @@ -54,15 +54,13 @@ org.osgi.service.url;version="[1.0,2)", org.osgi.service.cm;version="[1.5,2)";resolution:=optional, org.osgi.util.tracker;version="[1.5,2)", + org.apache.karaf.features.spi, javax.net.ssl, javax.xml.parsers, org.w3c.dom, org.xml.sax, * - - org.apache.karaf.services.url;version="${project.version}" - org.apache.karaf.services.url.internal, org.apache.karaf.services.url.internal.osgi, @@ -90,7 +88,8 @@ org.osgi.service.url.URLStreamHandlerService, - org.apache.karaf.services.url.MavenResolver + org.apache.karaf.features.spi.MavenResolverFactory, + org.apache.karaf.features.spi.MavenResolver @@ -133,6 +132,12 @@ org.apache.karaf.util provided + + + org.apache.karaf.features + org.apache.karaf.features.core + provided + biz.aQute.bnd biz.aQute.bndlib diff --git a/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java b/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java deleted file mode 100644 index 529b0034406..00000000000 --- a/services/url/src/main/java/org/apache/karaf/services/url/MavenResolver.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.karaf.services.url; - -import java.io.File; -import java.io.IOException; - -/** - * Service interface for resolving Maven artifact URIs. - *

- * Maven URIs follow the syntax: - * {@code mvn:[repository_url!]groupId/artifactId[/[version][/[type][/classifier]]]} - */ -public interface MavenResolver { - - /** - * Resolve a Maven artifact URI to a local file. - * - * @param url the Maven artifact URI (e.g. {@code mvn:groupId/artifactId/version}) - * @return the resolved file - * @throws IOException if the artifact cannot be resolved - */ - File resolve(String url) throws IOException; - - /** - * Resolve a Maven artifact by its coordinates to a local file. - * - * @param groupId the group ID - * @param artifactId the artifact ID - * @param version the version (can be {@code null} for LATEST) - * @param type the type/extension (can be {@code null} for jar) - * @param classifier the classifier (can be {@code null}) - * @return the resolved file - * @throws IOException if the artifact cannot be resolved - */ - File resolve(String groupId, String artifactId, String version, String type, String classifier) throws IOException; - - /** - * Get the local repository path. - * - * @return the local repository directory - */ - File getLocalRepository(); - -} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactory.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactory.java new file mode 100644 index 00000000000..5e801c210c7 --- /dev/null +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactory.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.util.Dictionary; + +import org.apache.karaf.features.spi.MavenResolver; +import org.apache.karaf.features.spi.MavenResolverFactory; +import org.osgi.framework.BundleContext; + +/** + * {@link MavenResolverFactory} creating the Karaf URL service Maven resolver, configured with the + * {@link MavenConfiguration#PID} PID. + */ +public class KarafMavenResolverFactory implements MavenResolverFactory { + + private final BundleContext bundleContext; + + /** + * Used by {@link java.util.ServiceLoader}, outside the OSGi framework: the variables of the configuration are + * resolved with the system properties only. + */ + public KarafMavenResolverFactory() { + this(null); + } + + public KarafMavenResolverFactory(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Override + public String getConfigurationPid() { + return MavenConfiguration.PID; + } + + @Override + public MavenResolver create(Dictionary configuration, String propertyPrefix) { + return new MavenResolverImpl(new MavenConfiguration(bundleContext, configuration, propertyPrefix)); + } + +} diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java index bf814caa4d0..f74bad1120e 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java @@ -55,7 +55,20 @@ public class MavenConfiguration { private boolean certificateCheck; private int retryCount; + private final String propertyPrefix; + public MavenConfiguration(BundleContext context, Dictionary properties) { + this(context, properties, PID); + } + + /** + * @param context used to look up the framework properties, null to only use the system properties + * @param properties the configuration (can be null) + * @param propertyPrefix the prefix of the keys to read from properties + */ + public MavenConfiguration(BundleContext context, Dictionary properties, String propertyPrefix) { + this.propertyPrefix = propertyPrefix; + // Local repository String localRepo = getProperty(context, properties, PROP_LOCAL_REPOSITORY); if (localRepo != null && !localRepo.isEmpty()) { @@ -89,7 +102,7 @@ public MavenConfiguration(BundleContext context, Dictionary propertie } private String getProperty(BundleContext context, Dictionary properties, String key) { - String fullKey = PID + "." + key; + String fullKey = propertyPrefix + "." + key; // Check dictionary first if (properties != null) { Object value = properties.get(fullKey); @@ -101,10 +114,7 @@ private String getProperty(BundleContext context, Dictionary properti } } // Fall back to system/framework properties - String value = context.getProperty(fullKey); - if (value == null) { - value = System.getProperty(fullKey); - } + String value = getFrameworkProperty(context, fullKey); if (value != null) { return substituteVars(context, value); } @@ -150,7 +160,7 @@ private File resolveFile(BundleContext context, String path) { path = substituteVars(context, path); File file = new File(path); if (!file.isAbsolute()) { - String karafHome = context.getProperty("karaf.home"); + String karafHome = getFrameworkProperty(context, "karaf.home"); if (karafHome != null) { file = new File(karafHome, path); } @@ -169,10 +179,7 @@ private String substituteVars(BundleContext context, String value) { int end = value.indexOf('}', i + 2); if (end > 0) { String varName = value.substring(i + 2, end); - String varValue = context.getProperty(varName); - if (varValue == null) { - varValue = System.getProperty(varName); - } + String varValue = getFrameworkProperty(context, varName); if (varValue != null) { result.append(varValue); } else { @@ -191,6 +198,14 @@ private String substituteVars(BundleContext context, String value) { return result.toString(); } + private static String getFrameworkProperty(BundleContext context, String name) { + String value = context != null ? context.getProperty(name) : null; + if (value == null) { + value = System.getProperty(name); + } + return value; + } + public File getLocalRepository() { return localRepository; } diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java index 3beee4da226..73ce438866c 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java @@ -36,7 +36,7 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; -import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.features.spi.MavenResolver; import org.apache.karaf.util.maven.Parser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,17 +73,17 @@ public File resolve(String url) throws IOException { } @Override - public File resolve(String groupId, String artifactId, String version, String type, String classifier) throws IOException { + public File resolve(String groupId, String artifactId, String classifier, String extension, String version) throws IOException { StringBuilder sb = new StringBuilder(); sb.append(groupId).append('/').append(artifactId); if (version != null && !version.isEmpty()) { sb.append('/').append(version); } - if (type != null && !type.isEmpty()) { - sb.append('/').append(type); + if (extension != null && !extension.isEmpty()) { + sb.append('/').append(extension); } if (classifier != null && !classifier.isEmpty()) { - if (type == null || type.isEmpty()) { + if (extension == null || extension.isEmpty()) { sb.append('/'); } sb.append('/').append(classifier); @@ -93,8 +93,9 @@ public File resolve(String groupId, String artifactId, String version, String ty } @Override - public File getLocalRepository() { - return configuration.getLocalRepository(); + public RetryChance isRetryableException(Exception exception) { + // the downloads from the remote repositories are already retried (connection.retryCount) + return RetryChance.NEVER; } private File resolve(Parser parser) throws IOException { diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java index 42f72abe1c1..d31062b4779 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MvnUrlHandler.java @@ -25,7 +25,7 @@ import java.net.URL; import java.net.URLConnection; -import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.features.spi.MavenResolver; import org.osgi.service.url.AbstractURLStreamHandlerService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java index 9e2132ec4c4..8ab9bc9c5e6 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java @@ -20,7 +20,9 @@ import java.util.Hashtable; -import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.features.spi.MavenResolver; +import org.apache.karaf.features.spi.MavenResolverFactory; +import org.apache.karaf.services.url.internal.KarafMavenResolverFactory; import org.apache.karaf.services.url.internal.MavenConfiguration; import org.apache.karaf.services.url.internal.MavenResolverImpl; import org.apache.karaf.services.url.internal.MvnUrlHandler; @@ -33,7 +35,10 @@ import org.osgi.service.url.URLStreamHandlerService; @Services( - provides = @ProvideService(MavenResolver.class) + provides = { + @ProvideService(MavenResolverFactory.class), + @ProvideService(MavenResolver.class) + } ) @Managed(MavenConfiguration.PID) public class Activator extends BaseActivator implements ManagedService { @@ -43,7 +48,11 @@ protected void doStart() throws Exception { MavenConfiguration config = new MavenConfiguration(bundleContext, getConfiguration()); MavenResolverImpl resolver = new MavenResolverImpl(config); - register(MavenResolver.class, resolver); + // the features service creates its own resolver with the factory, the other components use the resolver + Hashtable providerProps = new Hashtable<>(); + providerProps.put("provider", "karaf-url"); + register(MavenResolverFactory.class, new KarafMavenResolverFactory(bundleContext), providerProps); + register(MavenResolver.class, resolver, providerProps); Hashtable props = new Hashtable<>(); props.put("url.handler.protocol", "mvn"); diff --git a/services/url/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory b/services/url/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory new file mode 100644 index 00000000000..dd77acdfbb8 --- /dev/null +++ b/services/url/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory @@ -0,0 +1,17 @@ +##--------------------------------------------------------------------------- +## Licensed to the Apache Software Foundation (ASF) under one or more +## contributor license agreements. See the NOTICE file distributed with +## this work for additional information regarding copyright ownership. +## The ASF licenses this file to You 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. +##--------------------------------------------------------------------------- +org.apache.karaf.services.url.internal.KarafMavenResolverFactory diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactoryTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactoryTest.java new file mode 100644 index 00000000000..5c0f6fd337c --- /dev/null +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/KarafMavenResolverFactoryTest.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.karaf.services.url.internal; + +import java.io.File; +import java.nio.file.Files; +import java.util.Dictionary; +import java.util.Hashtable; + +import org.apache.karaf.features.spi.MavenResolver; +import org.apache.karaf.features.spi.MavenResolverFactory; +import org.apache.karaf.features.spi.MavenResolvers; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.junit.Assert.*; + +public class KarafMavenResolverFactoryTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testFactoryIsFoundWithServiceLoader() { + MavenResolverFactory factory = MavenResolvers.factory(); + + assertTrue(factory instanceof KarafMavenResolverFactory); + assertEquals(MavenConfiguration.PID, factory.getConfigurationPid()); + } + + @Test + public void testCreateWithoutBundleContext() throws Exception { + File systemRepo = tempFolder.newFolder("system"); + File artifact = createArtifact(systemRepo); + + Dictionary configuration = new Hashtable<>(); + configuration.put(MavenConfiguration.PID + ".localRepository", tempFolder.newFolder("local").getAbsolutePath()); + configuration.put(MavenConfiguration.PID + ".defaultRepositories", systemRepo.toURI() + "@id=system.repository"); + + try (MavenResolver resolver = new KarafMavenResolverFactory().create(configuration)) { + assertEquals(artifact.getAbsolutePath(), resolver.resolve("mvn:org.example/lib/1.0.0").getAbsolutePath()); + } + } + + @Test + public void testCreateWithPropertyPrefix() throws Exception { + File systemRepo = tempFolder.newFolder("system"); + File artifact = createArtifact(systemRepo); + + Dictionary configuration = new Hashtable<>(); + configuration.put("custom.localRepository", tempFolder.newFolder("local").getAbsolutePath()); + configuration.put("custom.defaultRepositories", systemRepo.getAbsolutePath()); + + try (MavenResolver resolver = new KarafMavenResolverFactory().create(configuration, "custom")) { + assertEquals(artifact.getAbsolutePath(), resolver.resolve("org.example", "lib", null, null, "1.0.0").getAbsolutePath()); + } + } + + private static File createArtifact(File repository) throws Exception { + File artifact = new File(repository, "org/example/lib/1.0.0/lib-1.0.0.jar"); + artifact.getParentFile().mkdirs(); + Files.write(artifact.toPath(), "content".getBytes()); + return artifact; + } + +} diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java index f8a0a987de8..87042378f31 100644 --- a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java @@ -24,6 +24,7 @@ import java.util.Arrays; import java.util.Collections; +import org.apache.karaf.features.spi.MavenResolver; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -196,7 +197,7 @@ public void testResolveByCoordinates() throws Exception { MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); MavenResolverImpl resolver = new MavenResolverImpl(config); - File resolved = resolver.resolve("org.example", "coords-test", "3.0.0", null, null); + File resolved = resolver.resolve("org.example", "coords-test", null, null, "3.0.0"); assertNotNull(resolved); assertTrue(resolved.exists()); @@ -238,11 +239,26 @@ public void testResolveWithTypeAndClassifier() throws Exception { } @Test - public void testGetLocalRepository() { + public void testResolveByCoordinatesWithClassifierAndExtension() throws Exception { + File artifactDir = new File(localRepo, "org/example/classified/1.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "classified-1.0.0-features.xml"); + createFile(artifactFile, ""); + + MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("org.example", "classified", "features", "xml", "1.0.0"); + + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test + public void testFailedResolutionIsNotRetryable() { MavenConfiguration config = createConfig(localRepo, Collections.emptyList(), Collections.emptyList()); MavenResolverImpl resolver = new MavenResolverImpl(config); - assertEquals(localRepo, resolver.getLocalRepository()); + assertEquals(MavenResolver.RetryChance.NEVER, resolver.isRetryableException(new IOException("not found"))); } @Test diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java index 8f8ffbfcc53..c7e91a41bc0 100644 --- a/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MvnUrlHandlerTest.java @@ -25,7 +25,7 @@ import java.net.URL; import java.net.URLConnection; -import org.apache.karaf.services.url.MavenResolver; +import org.apache.karaf.features.spi.MavenResolver; import org.junit.After; import org.junit.Before; import org.junit.Test; From 37cff4d4d81366ac72261757c0f4c1e9d1fdba1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Sat, 3 Oct 2026 04:36:17 +0200 Subject: [PATCH 5/5] feat(assemblies): use the karaf.url service in the karaf-light distribution The karaf-light distribution now provides the mvn: and wrap: protocols with the native Karaf URL service instead of pax-url-aether and pax-url-wrap, and the features service resolves the Maven artifacts with it, through the Karaf Maven resolver SPI. - Add the url feature (Karaf URL service at start level 5 and its etc/org.apache.karaf.url.mvn.cfg configuration) to the standard features. - karaf-light uses url as startup feature and blacklists the pax-url-aether and pax-url-wrap bundles, and the org.apache.karaf.maven.resolver.paxurl provider of the framework feature (the wrap feature stays as marker for the wrap deployer and the wrap prerequisites). The unused Pax URL files are removed from the assembly. - The Karaf URL service declares its URL handler capabilities, supports the @multi repositories (KAR repositories), and waits for the ConfigAdmin configuration when org.apache.karaf.url.mvn.requireConfigAdminConfig is set (as in etc/config.properties). Until then, it provides neither the URL handlers nor the Maven resolver, so the features service uses the system repository. - Child instances get the Karaf URL service configuration and the features processing file of the root instance. - Document the Karaf URL service provider in the Maven resolver section of the manual. --- assemblies/apache-karaf-light/pom.xml | 26 ++++++ .../src/main/distribution/LICENSE | 7 ++ .../src/main/distribution/README.md | 4 + .../resources/etc/config.properties | 1 + assemblies/features/standard/pom.xml | 7 ++ .../standard/src/main/feature/feature.xml | 90 +++++++++++++++++++ bom/artifacts/pom.xml | 5 ++ .../core/internal/InstanceServiceImpl.java | 10 +++ .../instance/resources/etc/config.properties | 1 + .../internal/InstanceServiceImplTest.java | 4 + .../etc/org.apache.karaf.features.xml | 23 +++++ .../etc/org.apache.karaf.url.mvn.cfg | 20 +++++ .../asciidoc/developer-guide/extending.adoc | 5 ++ services/url/pom.xml | 4 + .../url/internal/MavenConfiguration.java | 5 ++ .../url/internal/MavenResolverImpl.java | 24 +++++ .../services/url/internal/osgi/Activator.java | 5 ++ .../url/internal/MavenResolverImplTest.java | 34 +++++++ 18 files changed, 275 insertions(+) create mode 100644 instance/src/test/resources/etc/org.apache.karaf.features.xml create mode 100644 instance/src/test/resources/etc/org.apache.karaf.url.mvn.cfg diff --git a/assemblies/apache-karaf-light/pom.xml b/assemblies/apache-karaf-light/pom.xml index 8f70f27a265..d20819f0a6a 100644 --- a/assemblies/apache-karaf-light/pom.xml +++ b/assemblies/apache-karaf-light/pom.xml @@ -155,6 +155,7 @@ eventadmin + url wrap @@ -176,6 +177,12 @@ service system + + + mvn:org.ops4j.pax.url/pax-url-aether + mvn:org.ops4j.pax.url/pax-url-wrap + mvn:org.apache.karaf.maven/org.apache.karaf.maven.resolver.paxurl + 17 @@ -200,6 +207,21 @@ + + remove-pax-url + prepare-package + + run + + + + + + + + + + @@ -231,6 +253,10 @@ use-simple-resolver none + + remove-pax-url + none + diff --git a/assemblies/apache-karaf-light/src/main/distribution/LICENSE b/assemblies/apache-karaf-light/src/main/distribution/LICENSE index c183a06db04..59240ed81c5 100644 --- a/assemblies/apache-karaf-light/src/main/distribution/LICENSE +++ b/assemblies/apache-karaf-light/src/main/distribution/LICENSE @@ -210,6 +210,13 @@ License: Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt ----------------------------------------------------------------------------- +This product bundles bnd. + +Project URL: https://bnd.bndtools.org/ +License: Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt + +----------------------------------------------------------------------------- + This product bundles istack-common-runtime. Project URL: https://github.com/eclipse-ee4j/jaxb-istack-commons diff --git a/assemblies/apache-karaf-light/src/main/distribution/README.md b/assemblies/apache-karaf-light/src/main/distribution/README.md index 167ff017f4c..f8912f7d3c2 100644 --- a/assemblies/apache-karaf-light/src/main/distribution/README.md +++ b/assemblies/apache-karaf-light/src/main/distribution/README.md @@ -52,6 +52,10 @@ requirements/capabilities, which is faster and deterministic at the cost of not requirements, conditional features, and resource repositories. See the provisioning section of the [documentation](https://karaf.apache.org/manual/latest/#_feature_and_resolver) for details. +The light distribution also uses the Karaf URL service instead of Pax URL to provide the `mvn:` and `wrap:` +protocols, and to resolve the Maven artifacts of the features. The Maven repositories are configured in +`etc/org.apache.karaf.url.mvn.cfg`. + ## Prerequisites Apache Karaf requires a Java SE 11 or higher to run. diff --git a/assemblies/features/base/src/main/filtered-resources/resources/etc/config.properties b/assemblies/features/base/src/main/filtered-resources/resources/etc/config.properties index f480c801bec..4343f0edd71 100644 --- a/assemblies/features/base/src/main/filtered-resources/resources/etc/config.properties +++ b/assemblies/features/base/src/main/filtered-resources/resources/etc/config.properties @@ -311,6 +311,7 @@ org.apache.aries.proxy.weaving.enabled= # mvn url handler requires config instance configuration # org.ops4j.pax.url.mvn.requireConfigAdminConfig=true +org.apache.karaf.url.mvn.requireConfigAdminConfig=true # # Don't delay the console startup. Set to true if you want the console to start after all other bundles diff --git a/assemblies/features/standard/pom.xml b/assemblies/features/standard/pom.xml index 540a5919c4d..3c267d566f3 100644 --- a/assemblies/features/standard/pom.xml +++ b/assemblies/features/standard/pom.xml @@ -384,6 +384,13 @@ provided + + + org.apache.karaf.services + org.apache.karaf.services.url + provided + + org.jasypt diff --git a/assemblies/features/standard/src/main/feature/feature.xml b/assemblies/features/standard/src/main/feature/feature.xml index 06bb632a515..c9f96a12c48 100644 --- a/assemblies/features/standard/src/main/feature/feature.xml +++ b/assemblies/features/standard/src/main/feature/feature.xml @@ -1555,6 +1555,96 @@ org.apache.felix.eventadmin.AddSubject=true + + +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# +# Apache Karaf Maven URL Handler configuration +# +# This replaces the pax-url-mvn configuration with a native Karaf implementation. +# Property names are compatible with org.ops4j.pax.url.mvn.* for easier migration. +# + +# If set to true, SSL certificate validation is enforced when accessing +# Maven repositories through HTTPS +# +org.apache.karaf.url.mvn.certificateCheck = true + +# +# Path to the local Maven repository which is used to avoid downloading +# artifacts when they already exist locally. +# Defaults to: ${user.home}/.m2/repository +# +#org.apache.karaf.url.mvn.localRepository = + +# +# Comma separated list of default repositories (local, file-based). +# These are checked first, before the local repository and remote repositories. +# A repository URL can be appended with: +# @snapshots : the repository contains snapshots +# @noreleases : the repository does not contain releases +# @id=repo.id : the repository identifier +# +org.apache.karaf.url.mvn.defaultRepositories = \ + ${karaf.home.uri}${karaf.default.repository}@id=system.repository@snapshots, \ + ${karaf.data.uri}kar@id=kar.repository@multi@snapshots, \ + ${karaf.base.uri}${karaf.default.repository}@id=child.system.repository@snapshots + +# +# Comma separated list of remote repositories. +# These are accessed via HTTP/HTTPS when the artifact is not found locally. +# A repository URL can be appended with: +# @snapshots : the repository contains snapshots +# @noreleases : the repository does not contain releases +# @id=repo.id : the repository identifier +# +org.apache.karaf.url.mvn.repositories = \ + https://repo1.maven.org/maven2@id=central, \ + https://repository.apache.org/content/groups/snapshots-group@id=apache@snapshots@noreleases + +# +# Global update policy: always, daily, interval:NNN (minutes), never +# +#org.apache.karaf.url.mvn.globalUpdatePolicy = daily + +# +# Global checksum policy: warn, fail, ignore +# +#org.apache.karaf.url.mvn.globalChecksumPolicy = warn + +# +# Connection and socket timeouts +# +org.apache.karaf.url.mvn.timeout = 5000 +org.apache.karaf.url.mvn.socket.connectionTimeout = 5000 +org.apache.karaf.url.mvn.socket.readTimeout = 30000 + +# +# Number of connection retries after failure +# +org.apache.karaf.url.mvn.connection.retryCount = 3 + + mvn:org.apache.karaf.services/org.apache.karaf.services.url/${project.version} + + mvn:org.ops4j.pax.logging/pax-logging-api/${pax.logging.version} mvn:org.ops4j.pax.url/pax-url-wrap/${pax.url.version}/jar/uber diff --git a/bom/artifacts/pom.xml b/bom/artifacts/pom.xml index 81c383bd9d1..980d25339c7 100644 --- a/bom/artifacts/pom.xml +++ b/bom/artifacts/pom.xml @@ -364,6 +364,11 @@ org.apache.karaf.services.staticcm ${project.version} + + org.apache.karaf.services + org.apache.karaf.services.url + ${project.version} + org.apache.karaf.shell diff --git a/instance/src/main/java/org/apache/karaf/instance/core/internal/InstanceServiceImpl.java b/instance/src/main/java/org/apache/karaf/instance/core/internal/InstanceServiceImpl.java index e8537e9e157..b5cdcba7b7c 100644 --- a/instance/src/main/java/org/apache/karaf/instance/core/internal/InstanceServiceImpl.java +++ b/instance/src/main/java/org/apache/karaf/instance/core/internal/InstanceServiceImpl.java @@ -381,11 +381,21 @@ public synchronized Instance createInstance(final String name, final InstanceSet File rootEtc = new File(System.getProperty("karaf.etc")); copy(new File(rootEtc, "startup.properties"), new File(karafBase, "etc/startup.properties")); + // the startup.properties of the root instance may use the Karaf URL service, which needs its configuration + File rootUrlConfig = new File(rootEtc, "org.apache.karaf.url.mvn.cfg"); + if (rootUrlConfig.exists()) { + copy(rootUrlConfig, new File(karafBase, "etc/org.apache.karaf.url.mvn.cfg")); + } + // align child with any bundles we have overriden in the root instance File rootOverrides = new File(rootEtc, "overrides.properties"); if (rootOverrides.exists()) { copy(rootOverrides, new File(karafBase, "etc/overrides.properties")); } + File rootFeaturesProcessing = new File(rootEtc, "org.apache.karaf.features.xml"); + if (rootFeaturesProcessing.exists()) { + copy(rootFeaturesProcessing, new File(karafBase, "etc/org.apache.karaf.features.xml")); + } HashMap props = new HashMap<>(); props.put("${SUBST-KARAF-NAME}", name); diff --git a/instance/src/main/resources/org/apache/karaf/instance/resources/etc/config.properties b/instance/src/main/resources/org/apache/karaf/instance/resources/etc/config.properties index f59a2950eb4..ad23c48bc1d 100644 --- a/instance/src/main/resources/org/apache/karaf/instance/resources/etc/config.properties +++ b/instance/src/main/resources/org/apache/karaf/instance/resources/etc/config.properties @@ -287,6 +287,7 @@ org.apache.aries.proxy.weaving.enabled= # mvn url handler requires config instance configuration # org.ops4j.pax.url.mvn.requireConfigAdminConfig=true +org.apache.karaf.url.mvn.requireConfigAdminConfig=true # # Don't delay the console startup. Set to true if you want the console to start after all other bundles diff --git a/instance/src/test/java/org/apache/karaf/instance/core/internal/InstanceServiceImplTest.java b/instance/src/test/java/org/apache/karaf/instance/core/internal/InstanceServiceImplTest.java index e9cbaa1436e..fa2980bd651 100644 --- a/instance/src/test/java/org/apache/karaf/instance/core/internal/InstanceServiceImplTest.java +++ b/instance/src/test/java/org/apache/karaf/instance/core/internal/InstanceServiceImplTest.java @@ -98,6 +98,10 @@ public void testConfigurationFiles() throws Exception { assertFileExists(instance.getLocation(), "etc/org.apache.karaf.management.cfg"); assertFileExists(instance.getLocation(), "etc/org.ops4j.pax.logging.cfg"); assertFileExists(instance.getLocation(), "etc/org.ops4j.pax.url.mvn.cfg"); + + // files aligned with the root instance + assertFileExists(instance.getLocation(), "etc/org.apache.karaf.url.mvn.cfg"); + assertFileExists(instance.getLocation(), "etc/org.apache.karaf.features.xml"); } public void testTextResources() throws Exception { diff --git a/instance/src/test/resources/etc/org.apache.karaf.features.xml b/instance/src/test/resources/etc/org.apache.karaf.features.xml new file mode 100644 index 00000000000..35dd22cb88c --- /dev/null +++ b/instance/src/test/resources/etc/org.apache.karaf.features.xml @@ -0,0 +1,23 @@ + + + + + mvn:org.ops4j.pax.url/pax-url-aether + + diff --git a/instance/src/test/resources/etc/org.apache.karaf.url.mvn.cfg b/instance/src/test/resources/etc/org.apache.karaf.url.mvn.cfg new file mode 100644 index 00000000000..cb186a754e5 --- /dev/null +++ b/instance/src/test/resources/etc/org.apache.karaf.url.mvn.cfg @@ -0,0 +1,20 @@ +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +org.apache.karaf.url.mvn.defaultRepositories = ${karaf.home.uri}${karaf.default.repository}@id=system.repository diff --git a/manual/src/main/asciidoc/developer-guide/extending.adoc b/manual/src/main/asciidoc/developer-guide/extending.adoc index d8213706247..34a1cca25fc 100644 --- a/manual/src/main/asciidoc/developer-guide/extending.adoc +++ b/manual/src/main/asciidoc/developer-guide/extending.adoc @@ -69,6 +69,11 @@ and it is configured with `etc/org.ops4j.pax.url.mvn.cfg` (see <<_maven_url_hand You can replace this bundle with your own provider, for instance to create a distribution which does not contain Pax URL. +Apache Karaf provides another implementation, which does not depend on Pax URL: the `org.apache.karaf.services.url` bundle, +installed by the `url` feature. Besides the Maven resolver, it provides the `mvn` and `wrap` URL handlers, and it is +configured with `etc/org.apache.karaf.url.mvn.cfg`. The `apache-karaf-light` distribution uses it as a startup feature, +and blacklists the Pax URL bundles and the `org.apache.karaf.maven.resolver.paxurl` provider. + ===== Contract A provider implements two interfaces: diff --git a/services/url/pom.xml b/services/url/pom.xml index f48f55ba669..c20f6695f32 100644 --- a/services/url/pom.xml +++ b/services/url/pom.xml @@ -91,6 +91,10 @@ org.apache.karaf.features.spi.MavenResolverFactory, org.apache.karaf.features.spi.MavenResolver + + osgi.service;effective:=active;objectClass=org.osgi.service.url.URLStreamHandlerService;url.handler.protocol=mvn, + osgi.service;effective:=active;objectClass=org.osgi.service.url.URLStreamHandlerService;url.handler.protocol=wrap + diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java index f74bad1120e..133756cc1e2 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenConfiguration.java @@ -33,6 +33,11 @@ public class MavenConfiguration { public static final String PID = "org.apache.karaf.url.mvn"; + /** + * Framework property telling to wait for the configuration from ConfigAdmin before providing the URL handlers. + */ + public static final String PROP_REQUIRE_CONFIG_ADMIN_CONFIG = PID + ".requireConfigAdminConfig"; + public static final String PROP_LOCAL_REPOSITORY = "localRepository"; public static final String PROP_DEFAULT_REPOSITORIES = "defaultRepositories"; public static final String PROP_REPOSITORIES = "repositories"; diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java index 73ce438866c..3797bbc3bc6 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/MavenResolverImpl.java @@ -140,6 +140,20 @@ private File resolveFromDefaultRepository(String repo, String artifactPath) { repoDir = new File(repoUrl); } + if (hasFlag(repo, "multi")) { + // each sub directory is a repository (the KAR repositories for instance) + File[] repoDirs = repoDir.listFiles(File::isDirectory); + if (repoDirs != null) { + for (File dir : repoDirs) { + File file = new File(dir, artifactPath); + if (file.isFile()) { + return file; + } + } + } + return null; + } + File file = new File(repoDir, artifactPath); if (file.exists() && file.isFile()) { return file; @@ -338,6 +352,16 @@ public void checkServerTrusted(java.security.cert.X509Certificate[] certs, Strin } } + static boolean hasFlag(String repo, String flag) { + String[] flags = repo.split("@"); + for (int i = 1; i < flags.length; i++) { + if (flag.equals(flags[i].trim())) { + return true; + } + } + return false; + } + static String stripRepositoryFlags(String repo) { // Strip flags like @id=central @snapshots @noreleases @multi @update=... String result = repo; diff --git a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java index 8ab9bc9c5e6..8cb10921177 100644 --- a/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java +++ b/services/url/src/main/java/org/apache/karaf/services/url/internal/osgi/Activator.java @@ -45,6 +45,11 @@ public class Activator extends BaseActivator implements ManagedService { @Override protected void doStart() throws Exception { + if (getConfiguration() == null + && Boolean.parseBoolean(bundleContext.getProperty(MavenConfiguration.PROP_REQUIRE_CONFIG_ADMIN_CONFIG))) { + // do not provide the URL handlers and the Maven resolver before the actual repositories are known + return; + } MavenConfiguration config = new MavenConfiguration(bundleContext, getConfiguration()); MavenResolverImpl resolver = new MavenResolverImpl(config); diff --git a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java index 87042378f31..61253eb848d 100644 --- a/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java +++ b/services/url/src/test/java/org/apache/karaf/services/url/internal/MavenResolverImplTest.java @@ -125,6 +125,40 @@ public void testResolveFromDefaultRepositoryWithFlags() throws Exception { assertTrue(resolved.exists()); } + @Test + public void testResolveFromMultiDefaultRepository() throws Exception { + File karRepo = new File(tempDir, "kar"); + File artifactDir = new File(karRepo, "my-kar-1.0/com/example/mylib/2.0.0"); + artifactDir.mkdirs(); + File artifactFile = new File(artifactDir, "mylib-2.0.0.jar"); + createFile(artifactFile, "kar-repo-content"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(karRepo.toURI() + "@id=kar.repository@multi@snapshots"), + Collections.emptyList()); + MavenResolverImpl resolver = new MavenResolverImpl(config); + + File resolved = resolver.resolve("mvn:com.example/mylib/2.0.0"); + + assertEquals(artifactFile.getAbsolutePath(), resolved.getAbsolutePath()); + } + + @Test(expected = IOException.class) + public void testMultiDefaultRepositoryIsNotARepositoryItself() throws Exception { + File karRepo = new File(tempDir, "kar"); + File artifactDir = new File(karRepo, "com/example/mylib/2.0.0"); + artifactDir.mkdirs(); + createFile(new File(artifactDir, "mylib-2.0.0.jar"), "content"); + + MavenConfiguration config = createConfig( + localRepo, + Collections.singletonList(karRepo.getAbsolutePath() + "@id=kar.repository@multi"), + Collections.emptyList()); + + new MavenResolverImpl(config).resolve("mvn:com.example/mylib/2.0.0"); + } + @Test public void testResolveFromFileUriDefaultRepository() throws Exception { File defaultRepo = new File(tempDir, "system-repo");