You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2024/10/12 15:44:20 UTC

(maven-resolver) branch master updated: [MRESOLVER-541] S3 Transport using MinIO (#553)

This is an automated email from the ASF dual-hosted git repository.

cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new 7eb53786 [MRESOLVER-541] S3 Transport using MinIO (#553)
7eb53786 is described below

commit 7eb537860997bc9127d5d86e41964dcbf15b8e87
Author: Tamas Cservenak <ta...@cservenak.net>
AuthorDate: Sat Oct 12 17:44:15 2024 +0200

    [MRESOLVER-541] S3 Transport using MinIO (#553)
    
    This is a trivially simple S3 Transport with basic features, but allows Resolver transport with S3 protocol.
    
    ---
    
    https://issues.apache.org/jira/browse/MRESOLVER-541
---
 maven-resolver-named-locks-hazelcast/pom.xml       |   2 +-
 maven-resolver-named-locks-redisson/pom.xml        |   7 +-
 maven-resolver-tools/pom.xml                       |   2 +-
 .../pom.xml                                        |  81 +++++--
 .../aether/transport/minio/MinioTransporter.java   | 207 ++++++++++++++++++
 .../minio/MinioTransporterConfigurationKeys.java   |  59 ++++++
 .../transport/minio/MinioTransporterFactory.java   |  97 +++++++++
 .../eclipse/aether/transport/minio/ObjectName.java |  82 +++++++
 .../aether/transport/minio/ObjectNameMapper.java   |  31 +++
 .../transport/minio/ObjectNameMapperFactory.java   |  41 ++++
 .../FixedBucketObjectNameMapperFactory.java        |  57 +++++
 .../RepositoryIdObjectNameMapperFactory.java       |  50 +++++
 .../aether/transport/minio/package-info.java       |  29 +++
 .../minio/MinioTransporterFactoryTest.java         | 104 +++++++++
 .../aether/transport/minio/MinioTransporterIT.java | 236 +++++++++++++++++++++
 pom.xml                                            |  17 +-
 src/site/markdown/configuration.md                 |  24 ++-
 17 files changed, 1089 insertions(+), 37 deletions(-)

diff --git a/maven-resolver-named-locks-hazelcast/pom.xml b/maven-resolver-named-locks-hazelcast/pom.xml
index 141f1c79..b0c431ea 100644
--- a/maven-resolver-named-locks-hazelcast/pom.xml
+++ b/maven-resolver-named-locks-hazelcast/pom.xml
@@ -45,7 +45,7 @@
     <dependency>
       <groupId>com.hazelcast</groupId>
       <artifactId>hazelcast</artifactId>
-      <version>5.3.7</version>
+      <version>5.3.8</version>
     </dependency>
     <dependency>
       <groupId>javax.inject</groupId>
diff --git a/maven-resolver-named-locks-redisson/pom.xml b/maven-resolver-named-locks-redisson/pom.xml
index 703fa6ea..66016cb9 100644
--- a/maven-resolver-named-locks-redisson/pom.xml
+++ b/maven-resolver-named-locks-redisson/pom.xml
@@ -31,10 +31,6 @@
   <name>Maven Artifact Resolver Named Locks using Redisson</name>
   <description>A synchronization utility implementation using Redisson.</description>
 
-  <properties>
-    <testcontainersVersion>1.20.1</testcontainersVersion>
-  </properties>
-
   <dependencies>
     <dependency>
       <groupId>org.apache.maven.resolver</groupId>
@@ -49,7 +45,7 @@
     <dependency>
       <groupId>org.redisson</groupId>
       <artifactId>redisson</artifactId>
-      <version>3.34.1</version>
+      <version>3.37.0</version>
       <exclusions>
         <exclusion>
           <groupId>org.slf4j</groupId>
@@ -120,7 +116,6 @@
     <dependency>
       <groupId>org.apache.commons</groupId>
       <artifactId>commons-compress</artifactId>
-      <version>1.27.1</version>
       <scope>test</scope>
     </dependency>
     <dependency>
diff --git a/maven-resolver-tools/pom.xml b/maven-resolver-tools/pom.xml
index 443947c5..5e71f077 100644
--- a/maven-resolver-tools/pom.xml
+++ b/maven-resolver-tools/pom.xml
@@ -116,7 +116,7 @@
     <dependency>
       <groupId>org.apache.velocity</groupId>
       <artifactId>velocity-engine-core</artifactId>
-      <version>2.3</version>
+      <version>2.4</version>
     </dependency>
 
     <!-- Not needed during compile -->
diff --git a/maven-resolver-named-locks-hazelcast/pom.xml b/maven-resolver-transport-minio/pom.xml
similarity index 56%
copy from maven-resolver-named-locks-hazelcast/pom.xml
copy to maven-resolver-transport-minio/pom.xml
index 141f1c79..5ecd7780 100644
--- a/maven-resolver-named-locks-hazelcast/pom.xml
+++ b/maven-resolver-transport-minio/pom.xml
@@ -26,45 +26,78 @@
     <version>2.0.2-SNAPSHOT</version>
   </parent>
 
-  <artifactId>maven-resolver-named-locks-hazelcast</artifactId>
+  <artifactId>maven-resolver-transport-minio</artifactId>
+  <packaging>jar</packaging>
 
-  <name>Maven Artifact Resolver Named Locks using Hazelcast</name>
-  <description>A synchronization utility implementation using Hazelcast.</description>
+  <name>Maven Artifact Resolver Transport S3 MinIO</name>
+  <description>Maven Artifact Transport S3 MinIO (Java).</description>
+
+  <properties>
+    <javaVersion>8</javaVersion>
+  </properties>
 
   <dependencies>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.maven.resolver</groupId>
-      <artifactId>maven-resolver-named-locks</artifactId>
-      <scope>provided</scope>
+      <artifactId>maven-resolver-api</artifactId>
     </dependency>
     <dependency>
-      <groupId>org.slf4j</groupId>
-      <artifactId>slf4j-api</artifactId>
-      <scope>provided</scope>
+      <groupId>org.apache.maven.resolver</groupId>
+      <artifactId>maven-resolver-spi</artifactId>
     </dependency>
     <dependency>
-      <groupId>com.hazelcast</groupId>
-      <artifactId>hazelcast</artifactId>
-      <version>5.3.7</version>
+      <groupId>org.apache.maven.resolver</groupId>
+      <artifactId>maven-resolver-util</artifactId>
     </dependency>
     <dependency>
       <groupId>javax.inject</groupId>
       <artifactId>javax.inject</artifactId>
       <scope>provided</scope>
+      <optional>true</optional>
+    </dependency>
+
+    <dependency>
+      <groupId>io.minio</groupId>
+      <artifactId>minio</artifactId>
+      <version>8.5.11</version>
+    </dependency>
+
+    <!-- Overrides/Updates for Minio Java -->
+    <dependency>
+      <groupId>commons-codec</groupId>
+      <artifactId>commons-codec</artifactId>
+      <version>1.17.1</version>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-compress</artifactId>
+      <scope>runtime</scope>
     </dependency>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcprov-jdk18on</artifactId>
+      <version>1.78.1</version>
+      <scope>runtime</scope>
+    </dependency>
+
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter-api</artifactId>
       <scope>test</scope>
     </dependency>
     <dependency>
-      <groupId>org.mockito</groupId>
-      <artifactId>mockito-core</artifactId>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-simple</artifactId>
       <scope>test</scope>
     </dependency>
     <dependency>
-      <groupId>org.slf4j</groupId>
-      <artifactId>slf4j-simple</artifactId>
+      <groupId>org.apache.maven.resolver</groupId>
+      <artifactId>maven-resolver-test-util</artifactId>
       <scope>test</scope>
     </dependency>
     <dependency>
@@ -72,6 +105,24 @@
       <artifactId>maven-resolver-impl</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>testcontainers</artifactId>
+      <version>${testcontainersVersion}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <version>${testcontainersVersion}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>minio</artifactId>
+      <version>${testcontainersVersion}</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporter.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporter.java
new file mode 100644
index 00000000..6556774f
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporter.java
@@ -0,0 +1,207 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import io.minio.GetObjectArgs;
+import io.minio.MinioClient;
+import io.minio.StatObjectArgs;
+import io.minio.UploadObjectArgs;
+import io.minio.credentials.Provider;
+import io.minio.credentials.StaticProvider;
+import io.minio.errors.ErrorResponseException;
+import org.eclipse.aether.ConfigurationProperties;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.AuthenticationContext;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
+import org.eclipse.aether.spi.connector.transport.GetTask;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.PutTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.transfer.NoTransporterException;
+import org.eclipse.aether.util.ConfigUtils;
+import org.eclipse.aether.util.FileUtils;
+
+/**
+ * A transporter for S3 backed by MinIO Java.
+ *
+ * @since 2.0.2
+ */
+final class MinioTransporter extends AbstractTransporter implements Transporter {
+    private final URI baseUri;
+
+    private final Map<String, String> headers;
+
+    private final MinioClient client;
+
+    private final ObjectNameMapper objectNameMapper;
+
+    MinioTransporter(
+            RepositorySystemSession session,
+            RemoteRepository repository,
+            ObjectNameMapperFactory objectNameMapperFactory)
+            throws NoTransporterException {
+        try {
+            URI uri = new URI(repository.getUrl()).parseServerAuthority();
+            if (uri.isOpaque()) {
+                throw new URISyntaxException(repository.getUrl(), "URL must not be opaque");
+            }
+            if (uri.getRawFragment() != null || uri.getRawQuery() != null) {
+                throw new URISyntaxException(repository.getUrl(), "URL must not have fragment or query");
+            }
+            String path = uri.getPath();
+            if (path == null) {
+                path = "/";
+            }
+            if (!path.startsWith("/")) {
+                path = "/" + path;
+            }
+            if (!path.endsWith("/")) {
+                path = path + "/";
+            }
+            this.baseUri = URI.create(uri.getScheme() + "://" + uri.getRawAuthority() + path);
+        } catch (URISyntaxException e) {
+            throw new NoTransporterException(repository, e.getMessage(), e);
+        }
+
+        HashMap<String, String> headers = new HashMap<>();
+        @SuppressWarnings("unchecked")
+        Map<Object, Object> configuredHeaders = (Map<Object, Object>) ConfigUtils.getMap(
+                session,
+                Collections.emptyMap(),
+                ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
+                ConfigurationProperties.HTTP_HEADERS);
+        if (configuredHeaders != null) {
+            configuredHeaders.forEach((k, v) -> headers.put(String.valueOf(k), v != null ? String.valueOf(v) : null));
+        }
+        this.headers = headers;
+
+        String username = null;
+        String password = null;
+        try (AuthenticationContext repoAuthContext = AuthenticationContext.forRepository(session, repository)) {
+            if (repoAuthContext != null) {
+                username = repoAuthContext.get(AuthenticationContext.USERNAME);
+                password = repoAuthContext.get(AuthenticationContext.PASSWORD);
+            }
+        }
+        if (username == null || password == null) {
+            throw new NoTransporterException(repository, "No accessKey and/or secretKey provided");
+        }
+
+        Provider credentialsProvider = new StaticProvider(username, password, null);
+        try {
+            this.client = MinioClient.builder()
+                    .endpoint(repository.getUrl())
+                    .credentialsProvider(credentialsProvider)
+                    .build();
+        } catch (Exception e) {
+            throw new NoTransporterException(repository, e);
+        }
+        this.objectNameMapper = objectNameMapperFactory.create(session, repository, client, headers);
+    }
+
+    @Override
+    public int classify(Throwable error) {
+        if (error instanceof ErrorResponseException) {
+            String errorCode = ((ErrorResponseException) error).errorResponse().code();
+            if ("NoSuchKey".equals(errorCode) || "NoSuchBucket".equals(errorCode)) {
+                return ERROR_NOT_FOUND;
+            }
+        }
+        return ERROR_OTHER;
+    }
+
+    @Override
+    protected void implPeek(PeekTask task) throws Exception {
+        ObjectName objectName =
+                objectNameMapper.name(baseUri.relativize(task.getLocation()).getPath());
+        StatObjectArgs.Builder builder = StatObjectArgs.builder()
+                .bucket(objectName.getBucket())
+                .object(objectName.getName())
+                .extraHeaders(headers);
+        client.statObject(builder.build());
+    }
+
+    @Override
+    protected void implGet(GetTask task) throws Exception {
+        ObjectName objectName =
+                objectNameMapper.name(baseUri.relativize(task.getLocation()).getPath());
+        try (InputStream stream = client.getObject(GetObjectArgs.builder()
+                .bucket(objectName.getBucket())
+                .object(objectName.getName())
+                .extraHeaders(headers)
+                .build())) {
+            final Path dataFile = task.getDataPath();
+            if (dataFile == null) {
+                utilGet(task, stream, true, -1, false);
+            } else {
+                try (FileUtils.CollocatedTempFile tempFile = FileUtils.newTempFile(dataFile)) {
+                    task.setDataPath(tempFile.getPath(), false);
+                    utilGet(task, stream, true, -1, false);
+                    tempFile.move();
+                } finally {
+                    task.setDataPath(dataFile);
+                }
+            }
+        }
+    }
+
+    @Override
+    protected void implPut(PutTask task) throws Exception {
+        ObjectName objectName =
+                objectNameMapper.name(baseUri.relativize(task.getLocation()).getPath());
+        task.getListener().transportStarted(0, task.getDataLength());
+        final Path dataFile = task.getDataPath();
+        if (dataFile == null) {
+            try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
+                Files.copy(task.newInputStream(), tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
+                client.uploadObject(UploadObjectArgs.builder()
+                        .bucket(objectName.getBucket())
+                        .object(objectName.getName())
+                        .filename(tempFile.getPath().toString())
+                        .build());
+            }
+        } else {
+            client.uploadObject(UploadObjectArgs.builder()
+                    .bucket(objectName.getBucket())
+                    .object(objectName.getName())
+                    .filename(dataFile.toString())
+                    .build());
+        }
+    }
+
+    @Override
+    protected void implClose() {
+        try {
+            client.close();
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterConfigurationKeys.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterConfigurationKeys.java
new file mode 100644
index 00000000..cb7ef34a
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterConfigurationKeys.java
@@ -0,0 +1,59 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import org.eclipse.aether.ConfigurationProperties;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.transport.minio.internal.FixedBucketObjectNameMapperFactory;
+
+/**
+ * Configuration for MinIO Transport.
+ *
+ * @since 2.0.2
+ */
+public final class MinioTransporterConfigurationKeys {
+    private MinioTransporterConfigurationKeys() {}
+
+    static final String CONFIG_PROPS_PREFIX =
+            ConfigurationProperties.PREFIX_TRANSPORT + MinioTransporterFactory.NAME + ".";
+
+    /**
+     * Object name mapper to use.
+     *
+     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
+     * @configurationType {@link String}
+     * @configurationDefaultValue {@link #DEFAULT_OBJECT_NAME_MAPPER}
+     * @configurationRepoIdSuffix Yes
+     */
+    public static final String CONFIG_PROP_OBJECT_NAME_MAPPER = CONFIG_PROPS_PREFIX + "objectNameMapper";
+
+    public static final String DEFAULT_OBJECT_NAME_MAPPER = FixedBucketObjectNameMapperFactory.NAME;
+
+    /**
+     * The fixed bucket name to use.
+     *
+     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
+     * @configurationType {@link String}
+     * @configurationDefaultValue {@link #DEFAULT_FIXED_BUCKET_NAME}
+     * @configurationRepoIdSuffix Yes
+     */
+    public static final String CONFIG_PROP_FIXED_BUCKET_NAME = CONFIG_PROPS_PREFIX + "fixedBucketName";
+
+    public static final String DEFAULT_FIXED_BUCKET_NAME = "maven";
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterFactory.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterFactory.java
new file mode 100644
index 00000000..b8d9338e
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/MinioTransporterFactory.java
@@ -0,0 +1,97 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+
+import java.util.Map;
+
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.spi.connector.transport.TransporterFactory;
+import org.eclipse.aether.transfer.NoTransporterException;
+import org.eclipse.aether.util.ConfigUtils;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A transporter factory for repositories using the S3 API object storage using Minio.
+ *
+ * @since 2.0.2
+ */
+@Named(MinioTransporterFactory.NAME)
+public final class MinioTransporterFactory implements TransporterFactory {
+    public static final String NAME = "minio";
+
+    private static final float DEFAULT_PRIORITY = 0.0f;
+
+    private float priority = DEFAULT_PRIORITY;
+
+    private final Map<String, ObjectNameMapperFactory> objectNameMapperFactories;
+
+    @Inject
+    public MinioTransporterFactory(Map<String, ObjectNameMapperFactory> objectNameMapperFactories) {
+        this.objectNameMapperFactories = requireNonNull(objectNameMapperFactories, "objectNameMapperFactories");
+    }
+
+    @Override
+    public float getPriority() {
+        return priority;
+    }
+
+    public MinioTransporterFactory setPriority(float priority) {
+        this.priority = priority;
+        return this;
+    }
+
+    @Override
+    public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository)
+            throws NoTransporterException {
+        requireNonNull(session, "session cannot be null");
+        requireNonNull(repository, "repository cannot be null");
+
+        // this check is here only to support "minio+http" and "s3+http" protocols by default. But also when
+        // raised priorities by user, allow to "overtake" plain HTTP repositories, if needed.
+        RemoteRepository adjusted = repository;
+        if ("minio+http".equalsIgnoreCase(repository.getProtocol())
+                || "minio+https".equalsIgnoreCase(repository.getProtocol())) {
+            adjusted = new RemoteRepository.Builder(repository)
+                    .setUrl(repository.getUrl().substring("minio+".length()))
+                    .build();
+        } else if ("s3+http".equalsIgnoreCase(repository.getProtocol())
+                || "s3+https".equalsIgnoreCase(repository.getProtocol())) {
+            adjusted = new RemoteRepository.Builder(repository)
+                    .setUrl(repository.getUrl().substring("s3+".length()))
+                    .build();
+        } else if (priority == DEFAULT_PRIORITY) {
+            throw new NoTransporterException(repository);
+        }
+        String objectNameMapperConf = ConfigUtils.getString(
+                session,
+                MinioTransporterConfigurationKeys.DEFAULT_OBJECT_NAME_MAPPER,
+                MinioTransporterConfigurationKeys.CONFIG_PROP_OBJECT_NAME_MAPPER);
+        ObjectNameMapperFactory objectNameMapperFactory = objectNameMapperFactories.get(objectNameMapperConf);
+        if (objectNameMapperFactory == null) {
+            throw new NoTransporterException(repository, "Unknown object name mapper: " + objectNameMapperConf);
+        }
+        return new MinioTransporter(session, adjusted, objectNameMapperFactory);
+    }
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectName.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectName.java
new file mode 100644
index 00000000..5bc29244
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectName.java
@@ -0,0 +1,82 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import java.util.Objects;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * S3 Object name, bucket + name.
+ *
+ * @since 2.0.2
+ */
+public final class ObjectName {
+    private final String bucket;
+    private final String name;
+    private final int hashCode;
+
+    public ObjectName(String bucket, String name) {
+        this.bucket = requireNonNull(bucket);
+        this.name = requireNonNull(name);
+
+        if (bucket.contains("/")) {
+            throw new IllegalArgumentException("invalid bucket name: " + bucket);
+        }
+        if (name.contains("\\")) {
+            throw new IllegalArgumentException("invalid object name: " + name);
+        }
+
+        this.hashCode = Objects.hash(bucket, name);
+    }
+
+    public String getBucket() {
+        return bucket;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        ObjectName that = (ObjectName) o;
+        return Objects.equals(bucket, that.bucket) && Objects.equals(name, that.name);
+    }
+
+    @Override
+    public int hashCode() {
+        return hashCode;
+    }
+
+    @Override
+    public String toString() {
+        return bucket + "/" + name;
+    }
+
+    public static String normalize(String name) {
+        return name.replace('\\', '/');
+    }
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapper.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapper.java
new file mode 100644
index 00000000..3a5b8a25
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapper.java
@@ -0,0 +1,31 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+/**
+ * Mapping for {@link ObjectName}.
+ *
+ * @since 2.0.2
+ */
+public interface ObjectNameMapper {
+    /**
+     * Creates a mapping onto {@link ObjectName}. Should never return {@code null}.
+     */
+    ObjectName name(String path);
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapperFactory.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapperFactory.java
new file mode 100644
index 00000000..5879a2eb
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/ObjectNameMapperFactory.java
@@ -0,0 +1,41 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import java.util.Map;
+
+import io.minio.MinioClient;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+
+/**
+ * Factory for {@link ObjectNameMapper}.
+ *
+ * @since 2.0.2
+ */
+public interface ObjectNameMapperFactory {
+    /**
+     * Creates a factory for mapping artifact layout path onto {@link ObjectName}. Should never return {@code null}.
+     */
+    ObjectNameMapper create(
+            RepositorySystemSession session,
+            RemoteRepository repository,
+            MinioClient minioClient,
+            Map<String, String> extraHeaders);
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/FixedBucketObjectNameMapperFactory.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/FixedBucketObjectNameMapperFactory.java
new file mode 100644
index 00000000..01de8dc3
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/FixedBucketObjectNameMapperFactory.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.eclipse.aether.transport.minio.internal;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.Map;
+
+import io.minio.MinioClient;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.transport.minio.MinioTransporterConfigurationKeys;
+import org.eclipse.aether.transport.minio.ObjectName;
+import org.eclipse.aether.transport.minio.ObjectNameMapper;
+import org.eclipse.aether.transport.minio.ObjectNameMapperFactory;
+import org.eclipse.aether.util.ConfigUtils;
+
+/**
+ * A fixed bucket mapper, uses given bucket ID and then constructs object name using repository ID and layout path as
+ * object name.
+ */
+@Singleton
+@Named(FixedBucketObjectNameMapperFactory.NAME)
+public class FixedBucketObjectNameMapperFactory implements ObjectNameMapperFactory {
+    public static final String NAME = "fixedBucket";
+
+    @Override
+    public ObjectNameMapper create(
+            RepositorySystemSession session,
+            RemoteRepository repository,
+            MinioClient unused,
+            Map<String, String> headers) {
+        String bucket = ConfigUtils.getString(
+                session,
+                MinioTransporterConfigurationKeys.CONFIG_PROP_FIXED_BUCKET_NAME,
+                MinioTransporterConfigurationKeys.CONFIG_PROP_FIXED_BUCKET_NAME + "." + repository.getId(),
+                MinioTransporterConfigurationKeys.DEFAULT_FIXED_BUCKET_NAME);
+        return path -> new ObjectName(bucket, repository.getId() + "/" + ObjectName.normalize(path));
+    }
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/RepositoryIdObjectNameMapperFactory.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/RepositoryIdObjectNameMapperFactory.java
new file mode 100644
index 00000000..bd07b07a
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/internal/RepositoryIdObjectNameMapperFactory.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.eclipse.aether.transport.minio.internal;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.util.Map;
+
+import io.minio.MinioClient;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.transport.minio.ObjectName;
+import org.eclipse.aether.transport.minio.ObjectNameMapper;
+import org.eclipse.aether.transport.minio.ObjectNameMapperFactory;
+
+/**
+ * A mapper that uses repository ID as bucket name and layout path as object name. Assumes repository ID is a valid
+ * bucket name.
+ */
+@Singleton
+@Named(RepositoryIdObjectNameMapperFactory.NAME)
+public class RepositoryIdObjectNameMapperFactory implements ObjectNameMapperFactory {
+    public static final String NAME = "repositoryId";
+
+    @Override
+    public ObjectNameMapper create(
+            RepositorySystemSession session,
+            RemoteRepository repository,
+            MinioClient unused,
+            Map<String, String> headers) {
+        return path -> new ObjectName(repository.getId(), ObjectName.normalize(path));
+    }
+}
diff --git a/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/package-info.java b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/package-info.java
new file mode 100644
index 00000000..a1619e63
--- /dev/null
+++ b/maven-resolver-transport-minio/src/main/java/org/eclipse/aether/transport/minio/package-info.java
@@ -0,0 +1,29 @@
+// CHECKSTYLE_OFF: RegexpHeader
+/*
+ * 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.
+ */
+/**
+ * Support for downloads/uploads via the S3 protocol. The implementation is backed by
+ * <a href="https://github.com/minio/minio-java">MinIO Java</a>.
+ * The repository URL should be defined with protocol {@code minio+http} or {@code s3+http}. Note: use "https" if
+ * you are going for HTTPS remote, factory will merely strip "minio+" or "s3+" prefix assuming resulting URL will
+ * point to expected S3 endpoint.
+ *
+ * @since 2.0.2
+ */
+package org.eclipse.aether.transport.minio;
diff --git a/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterFactoryTest.java b/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterFactoryTest.java
new file mode 100644
index 00000000..7ce8d235
--- /dev/null
+++ b/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterFactoryTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.transfer.NoTransporterException;
+import org.eclipse.aether.transport.minio.internal.FixedBucketObjectNameMapperFactory;
+import org.eclipse.aether.transport.minio.internal.RepositoryIdObjectNameMapperFactory;
+import org.eclipse.aether.util.repository.AuthenticationBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * MinIO transporter UT.
+ */
+class MinioTransporterFactoryTest {
+    private Map<String, ObjectNameMapperFactory> factories;
+    private MinioTransporterFactory factory;
+    private RepositorySystemSession session;
+    private RemoteRepository repository;
+
+    @BeforeEach
+    void startSuite() throws Exception {
+        Files.createDirectories(Paths.get(System.getProperty("java.io.tmpdir"))); // hack for Surefire
+
+        factories = new HashMap<>();
+        factories.put(RepositoryIdObjectNameMapperFactory.NAME, new RepositoryIdObjectNameMapperFactory());
+        factories.put(FixedBucketObjectNameMapperFactory.NAME, new FixedBucketObjectNameMapperFactory());
+
+        factory = new MinioTransporterFactory(factories);
+        session = new DefaultRepositorySystemSession(h -> true);
+        repository = new RemoteRepository.Builder("repo", "default", "minio+http://localhost")
+                .setAuthentication(new AuthenticationBuilder()
+                        .addUsername("username")
+                        .addPassword("password")
+                        .build())
+                .build();
+    }
+
+    @AfterEach
+    public void stopSuite() {
+        // none
+    }
+
+    @Test
+    void goodUrl() throws Exception {
+        try (Transporter transporter = factory.newInstance(session, repository)) {
+            // nope
+        }
+    }
+
+    @Test
+    void wrongUrl() throws Exception {
+        final AtomicReference<RemoteRepository> remoteRepository = new AtomicReference<>();
+
+        remoteRepository.set(new RemoteRepository.Builder("repo", "default", "http://localhost").build());
+        assertThrows(NoTransporterException.class, () -> factory.newInstance(session, remoteRepository.get()));
+        remoteRepository.set(new RemoteRepository.Builder("repo", "default", "https://localhost").build());
+        assertThrows(NoTransporterException.class, () -> factory.newInstance(session, remoteRepository.get()));
+    }
+
+    @Test
+    void httpOvertake() throws Exception {
+        factory.setPriority(1000);
+        try (Transporter transporter = factory.newInstance(
+                session,
+                new RemoteRepository.Builder("repo", "default", "http://localhost")
+                        .setAuthentication(new AuthenticationBuilder()
+                                .addUsername("username")
+                                .addPassword("password")
+                                .build())
+                        .build())) {
+            // nope
+        }
+    }
+}
diff --git a/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterIT.java b/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterIT.java
new file mode 100644
index 00000000..1b4344f6
--- /dev/null
+++ b/maven-resolver-transport-minio/src/test/java/org/eclipse/aether/transport/minio/MinioTransporterIT.java
@@ -0,0 +1,236 @@
+/*
+ * 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.eclipse.aether.transport.minio;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import io.minio.UploadObjectArgs;
+import io.minio.errors.ErrorResponseException;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.GetTask;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.PutTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.transfer.NoTransporterException;
+import org.eclipse.aether.transport.minio.internal.RepositoryIdObjectNameMapperFactory;
+import org.eclipse.aether.util.FileUtils;
+import org.eclipse.aether.util.repository.AuthenticationBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.MinIOContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * MinIO transporter UT.
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class MinioTransporterIT {
+    private static final String BUCKET_NAME = "minio-repo";
+    private static final String OBJECT_NAME = "dir/file.txt";
+    private static final String OBJECT_CONTENT = "content";
+
+    private MinIOContainer minioContainer;
+    private MinioClient minioClient;
+    private RepositorySystemSession session;
+    private RemoteRepository repository;
+    private ObjectNameMapperFactory objectNameMapperFactory;
+
+    @BeforeEach
+    void startSuite() throws Exception {
+        Files.createDirectories(Paths.get(System.getProperty("java.io.tmpdir"))); // hack for Surefire
+
+        minioContainer = new MinIOContainer("minio/minio:latest");
+        minioContainer.start();
+        minioClient = MinioClient.builder()
+                .endpoint(minioContainer.getS3URL())
+                .credentials(minioContainer.getUserName(), minioContainer.getPassword())
+                .build();
+
+        minioClient.makeBucket(MakeBucketArgs.builder().bucket(BUCKET_NAME).build());
+        try (FileUtils.TempFile tempFile = FileUtils.newTempFile()) {
+            Files.write(tempFile.getPath(), OBJECT_CONTENT.getBytes(StandardCharsets.UTF_8));
+            minioClient.uploadObject(UploadObjectArgs.builder()
+                    .bucket(BUCKET_NAME)
+                    .object(OBJECT_NAME)
+                    .filename(tempFile.getPath().toString())
+                    .build());
+        }
+
+        session = new DefaultRepositorySystemSession(h -> true);
+        repository = newRepo(RepositoryAuth.WITH);
+        objectNameMapperFactory = new RepositoryIdObjectNameMapperFactory();
+    }
+
+    @AfterEach
+    public void stopSuite() {
+        minioContainer.start();
+    }
+
+    enum RepositoryAuth {
+        WITHOUT,
+        WITH,
+        WRONG
+    }
+
+    protected RemoteRepository newRepo(RepositoryAuth auth) {
+        RemoteRepository.Builder builder =
+                new RemoteRepository.Builder(BUCKET_NAME, "default", minioContainer.getS3URL());
+        if (auth == RepositoryAuth.WITH) {
+            builder.setAuthentication(new AuthenticationBuilder()
+                    .addUsername(minioContainer.getUserName())
+                    .addPassword(minioContainer.getPassword())
+                    .build());
+        } else if (auth == RepositoryAuth.WRONG) {
+            builder.setAuthentication(new AuthenticationBuilder()
+                    .addUsername("wrongusername")
+                    .addPassword("wrongpassword")
+                    .build());
+        }
+        return builder.build();
+    }
+
+    @Test
+    void peekWithoutAuth() {
+        try {
+            new MinioTransporter(session, newRepo(RepositoryAuth.WITHOUT), objectNameMapperFactory);
+            fail("Should throw");
+        } catch (NoTransporterException e) {
+            assertTrue(e.getMessage().contains("No accessKey and/or secretKey provided"));
+        }
+    }
+
+    @Test
+    void peekWithWrongAuth() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WRONG), objectNameMapperFactory)) {
+            try {
+                transporter.peek(new PeekTask(URI.create("test")));
+                fail("Should throw");
+            } catch (Exception e) {
+                assertInstanceOf(ErrorResponseException.class, e);
+                assertEquals(transporter.classify(e), Transporter.ERROR_OTHER);
+            }
+        }
+    }
+
+    @Test
+    void peekNonexistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                transporter.peek(new PeekTask(URI.create("test")));
+                fail("Should throw");
+            } catch (Exception e) {
+                assertInstanceOf(ErrorResponseException.class, e);
+                assertEquals(transporter.classify(e), Transporter.ERROR_NOT_FOUND);
+            }
+        }
+    }
+
+    @Test
+    void peekExistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                transporter.peek(new PeekTask(URI.create(OBJECT_NAME)));
+            } catch (Exception e) {
+                Assertions.fail("Should not throw");
+            }
+        }
+    }
+
+    @Test
+    void getNonexistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                transporter.get(new GetTask(URI.create("test")));
+                fail("Should throw");
+            } catch (Exception e) {
+                assertInstanceOf(ErrorResponseException.class, e);
+                assertEquals(transporter.classify(e), Transporter.ERROR_NOT_FOUND);
+            }
+        }
+    }
+
+    @Test
+    void getExistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                GetTask task = new GetTask(URI.create(OBJECT_NAME));
+                transporter.get(task);
+                assertEquals(OBJECT_CONTENT, new String(task.getDataBytes(), StandardCharsets.UTF_8));
+            } catch (Exception e) {
+                Assertions.fail("Should not throw");
+            }
+        }
+    }
+
+    @Test
+    void putNonexistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                URI uri = URI.create("test");
+                transporter.put(new PutTask(uri).setDataBytes(OBJECT_CONTENT.getBytes(StandardCharsets.UTF_8)));
+                GetTask task = new GetTask(uri);
+                transporter.get(task);
+                assertEquals(OBJECT_CONTENT, new String(task.getDataBytes(), StandardCharsets.UTF_8));
+            } catch (Exception e) {
+                Assertions.fail("Should not throw");
+            }
+        }
+    }
+
+    @Test
+    void putExistent() throws Exception {
+        try (MinioTransporter transporter =
+                new MinioTransporter(session, newRepo(RepositoryAuth.WITH), objectNameMapperFactory)) {
+            try {
+                URI uri = URI.create(OBJECT_NAME);
+                GetTask task = new GetTask(uri);
+                transporter.get(task);
+                assertEquals(OBJECT_CONTENT, new String(task.getDataBytes(), StandardCharsets.UTF_8));
+
+                String altContent = "altContent";
+                transporter.put(new PutTask(uri).setDataBytes(altContent.getBytes(StandardCharsets.UTF_8)));
+                task = new GetTask(uri);
+                transporter.get(task);
+                assertEquals(altContent, new String(task.getDataBytes(), StandardCharsets.UTF_8));
+            } catch (Exception e) {
+                Assertions.fail("Should not throw");
+            }
+        }
+    }
+}
diff --git a/pom.xml b/pom.xml
index ea9ef811..b9f94606 100644
--- a/pom.xml
+++ b/pom.xml
@@ -62,6 +62,7 @@
     <module>maven-resolver-transport-jdk-parent</module>
     <module>maven-resolver-transport-apache</module>
     <module>maven-resolver-transport-wagon</module>
+    <module>maven-resolver-transport-minio</module>
     <module>maven-resolver-generator-gnupg</module>
     <module>maven-resolver-supplier-mvn3</module>
     <module>maven-resolver-supplier-mvn4</module>
@@ -102,6 +103,11 @@
     <sisuVersion>0.9.0.M3</sisuVersion>
     <guiceVersion>6.0.0</guiceVersion>
     <slf4jVersion>2.0.16</slf4jVersion>
+    <testcontainersVersion>1.20.2</testcontainersVersion>
+
+    <!-- Remove when parent updates -->
+    <versions.junit5>5.11.2</versions.junit5>
+
     <!-- Used by Jetty Transport (client) and Test HTTP (server) -->
     <jettyVersion>10.0.22</jettyVersion>
     <!-- used by supplier and demo only -->
@@ -227,7 +233,7 @@
       <dependency>
         <groupId>org.mockito</groupId>
         <artifactId>mockito-core</artifactId>
-        <version>5.12.0</version>
+        <version>5.14.1</version>
         <scope>test</scope>
       </dependency>
 
@@ -235,12 +241,12 @@
       <dependency>
         <groupId>org.codehaus.plexus</groupId>
         <artifactId>plexus-utils</artifactId>
-        <version>4.0.1</version>
+        <version>4.0.2</version>
       </dependency>
       <dependency>
         <groupId>org.codehaus.plexus</groupId>
         <artifactId>plexus-xml</artifactId>
-        <version>4.0.4</version>
+        <version>3.0.1</version>
       </dependency>
 
       <dependency>
@@ -295,6 +301,11 @@
         <artifactId>gson</artifactId>
         <version>2.11.0</version>
       </dependency>
+      <dependency>
+        <groupId>org.apache.commons</groupId>
+        <artifactId>commons-compress</artifactId>
+        <version>1.27.1</version>
+      </dependency>
     </dependencies>
   </dependencyManagement>
 
diff --git a/src/site/markdown/configuration.md b/src/site/markdown/configuration.md
index c418b77b..6c8f65ea 100644
--- a/src/site/markdown/configuration.md
+++ b/src/site/markdown/configuration.md
@@ -132,17 +132,19 @@ under the License.
 | 105. | `"aether.transport.jdk.maxConcurrentRequests"` | `Integer` | The hard limit of maximum concurrent requests JDK transport can do. This is a workaround for the fact, that in HTTP/2 mode, JDK HttpClient initializes this value to Integer.MAX_VALUE (!) and lowers it on first response from the remote server (but it may be too late). See JDK bug <a href="https://bugs.openjdk.org/browse/JDK-8225647">JDK-8225647</a> for details. |  `100`  | 2.0.0 |  Yes  | Session Configuration |
 | 106. | `"aether.transport.jetty.followRedirects"` | `Boolean` | If enabled, Jetty client will follow HTTP redirects. |  `true`  | 2.0.1 |  Yes  | Session Configuration |
 | 107. | `"aether.transport.jetty.maxRedirects"` | `Integer` | The max redirect count to follow. |  `5`  | 2.0.1 |  Yes  | Session Configuration |
-| 108. | `"aether.transport.wagon.config"` | `Object` | The configuration to use for the Wagon provider. |  -  |  |  Yes  | Session Configuration |
-| 109. | `"aether.transport.wagon.perms.dirMode"` | `String` | Octal numerical notation of permissions to set for newly created directories. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
-| 110. | `"aether.transport.wagon.perms.fileMode"` | `String` | Octal numerical notation of permissions to set for newly created files. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
-| 111. | `"aether.transport.wagon.perms.group"` | `String` | Group which should own newly created directories/files. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
-| 112. | `"aether.trustedChecksumsSource.sparseDirectory"` | `Boolean` | Is checksum source enabled? |  `false`  | 1.9.0 |  No  | Session Configuration |
-| 113. | `"aether.trustedChecksumsSource.sparseDirectory.basedir"` | `String` | The basedir where checksums are. If relative, is resolved from local repository root. |  `".checksums"`  | 1.9.0 |  No  | Session Configuration |
-| 114. | `"aether.trustedChecksumsSource.sparseDirectory.originAware"` | `Boolean` | Is source origin aware? |  `true`  | 1.9.0 |  No  | Session Configuration |
-| 115. | `"aether.trustedChecksumsSource.summaryFile"` | `Boolean` | Is checksum source enabled? |  `false`  | 1.9.0 |  No  | Session Configuration |
-| 116. | `"aether.trustedChecksumsSource.summaryFile.basedir"` | `String` | The basedir where checksums are. If relative, is resolved from local repository root. |  `".checksums"`  | 1.9.0 |  No  | Session Configuration |
-| 117. | `"aether.trustedChecksumsSource.summaryFile.originAware"` | `Boolean` | Is source origin aware? |  `true`  | 1.9.0 |  No  | Session Configuration |
-| 118. | `"aether.updateCheckManager.sessionState"` | `String` | Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass" will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating those). All other values lead to disabling the session state completely. |  `"enabled"`  |  |  No  |  [...]
+| 108. | `"aether.transport.minio.fixedBucketName"` | `String` | The fixed bucket name to use. |  `"maven"`  | 2.0.2 |  Yes  | Session Configuration |
+| 109. | `"aether.transport.minio.objectNameMapper"` | `String` | Object name mapper to use. |  `"fixedBucket"`  | 2.0.2 |  Yes  | Session Configuration |
+| 110. | `"aether.transport.wagon.config"` | `Object` | The configuration to use for the Wagon provider. |  -  |  |  Yes  | Session Configuration |
+| 111. | `"aether.transport.wagon.perms.dirMode"` | `String` | Octal numerical notation of permissions to set for newly created directories. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
+| 112. | `"aether.transport.wagon.perms.fileMode"` | `String` | Octal numerical notation of permissions to set for newly created files. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
+| 113. | `"aether.transport.wagon.perms.group"` | `String` | Group which should own newly created directories/files. Only considered by certain Wagon providers. |  -  |  |  Yes  | Session Configuration |
+| 114. | `"aether.trustedChecksumsSource.sparseDirectory"` | `Boolean` | Is checksum source enabled? |  `false`  | 1.9.0 |  No  | Session Configuration |
+| 115. | `"aether.trustedChecksumsSource.sparseDirectory.basedir"` | `String` | The basedir where checksums are. If relative, is resolved from local repository root. |  `".checksums"`  | 1.9.0 |  No  | Session Configuration |
+| 116. | `"aether.trustedChecksumsSource.sparseDirectory.originAware"` | `Boolean` | Is source origin aware? |  `true`  | 1.9.0 |  No  | Session Configuration |
+| 117. | `"aether.trustedChecksumsSource.summaryFile"` | `Boolean` | Is checksum source enabled? |  `false`  | 1.9.0 |  No  | Session Configuration |
+| 118. | `"aether.trustedChecksumsSource.summaryFile.basedir"` | `String` | The basedir where checksums are. If relative, is resolved from local repository root. |  `".checksums"`  | 1.9.0 |  No  | Session Configuration |
+| 119. | `"aether.trustedChecksumsSource.summaryFile.originAware"` | `Boolean` | Is source origin aware? |  `true`  | 1.9.0 |  No  | Session Configuration |
+| 120. | `"aether.updateCheckManager.sessionState"` | `String` | Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass" will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating those). All other values lead to disabling the session state completely. |  `"enabled"`  |  |  No  |  [...]
 
 
 All properties which have `yes` in the column `Supports Repo ID Suffix` can be optionally configured specifically for a repository id. In that case the configuration property needs to be suffixed with a period followed by the repository id of the repository to configure, e.g. `aether.connector.http.headers.central` for repository with id `central`.