listBlobs(BlobPath path) throws BlobStoreException;
+
+ /**
+ * Copy a blob from one path to another within this store.
+ *
+ * @param sourcePath the source path
+ * @param targetPath the target path
+ * @return the copied blob at the target path
+ * @throws BlobStoreException if the copy operation fails
+ * @throws BlobNotFoundException if the source blob does not exist
+ * @throws BlobAlreadyExistsException if a blob already exists at the target path
+ */
+ Blob copyBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Copy a blob from another store to this store.
+ *
+ * @param sourceStore the source blob store
+ * @param sourcePath the path of the blob in the source store
+ * @param targetPath the path where the blob should be copied in this store
+ * @return the copied blob at the target path
+ * @throws BlobStoreException if the copy operation fails
+ * @throws BlobNotFoundException if the source blob does not exist
+ * @throws BlobAlreadyExistsException if a blob already exists at the target path
+ */
+ Blob copyBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Move a blob from one path to another within this store.
+ *
+ * @param sourcePath the source path
+ * @param targetPath the target path
+ * @return the moved blob at the target path
+ * @throws BlobStoreException if the move operation fails
+ * @throws BlobNotFoundException if the source blob does not exist
+ * @throws BlobAlreadyExistsException if a blob already exists at the target path
+ */
+ Blob moveBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Move a directory from one path to another within this store.
+ *
+ * This operation is not necessarily efficient and might move each blob individually.
+ *
+ * @param sourcePath the source path
+ * @param targetPath the target path
+ * @throws BlobStoreException if the move operation fails
+ */
+ void moveDirectory(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Move a directory from another store to this store.
+ *
+ * This operation is not necessarily efficient and might move each blob individually.
+ *
+ * @param sourceStore the source blob store
+ * @param sourcePath the path of the directory in the source store
+ * @param targetPath the path where the directory should be moved in this store
+ * @throws BlobStoreException if the move operation fails
+ */
+ void moveDirectory(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Check if a directory is empty (i.e., contains no blobs).
+ *
+ * Only child blobs under the given path prefix are considered. If there is a blob
+ * with the exact same path as the directory being checked, it is not counted
+ * when determining if the directory is empty. This maintains consistency with
+ * {@link #listBlobs(BlobPath)} which lists only child blobs under the path prefix.
+ *
+ * @param path the path of the directory to check
+ * @return true if the directory is empty, false otherwise
+ * @throws BlobStoreException if the check operation fails
+ */
+ boolean isEmptyDirectory(BlobPath path) throws BlobStoreException;
+
+ /**
+ * Move a blob from another store to this store.
+ *
+ * @param sourceStore the source blob store
+ * @param sourcePath the path of the blob in the source store
+ * @param targetPath the path where the blob should be moved in this store
+ * @return the moved blob at the target path
+ * @throws BlobStoreException if the move operation fails
+ * @throws BlobNotFoundException if the source blob does not exist
+ * @throws BlobAlreadyExistsException if a blob already exists at the target path
+ */
+ Blob moveBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException;
+
+ /**
+ * Delete the blob at the given path.
+ *
+ * @param path the path of the blob to delete
+ * @throws BlobStoreException if the deletion fails
+ */
+ void deleteBlob(BlobPath path) throws BlobStoreException;
+
+ /**
+ * Delete all blobs under the given path.
+ *
+ * @param path the path prefix to delete under
+ * @throws BlobStoreException if the deletion fails
+ */
+ void deleteBlobs(BlobPath path) throws BlobStoreException;
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreException.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreException.java
new file mode 100644
index 0000000000..24f0174311
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreException.java
@@ -0,0 +1,64 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import java.io.Serial;
+
+import org.xwiki.stability.Unstable;
+import org.xwiki.store.StoreException;
+
+/**
+ * Any exception raised in the XWiki BlobStore component must raise an exception of this type.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Unstable
+public class BlobStoreException extends StoreException
+{
+ /**
+ * Provides an id for serialization.
+ */
+ @Serial
+ private static final long serialVersionUID = -8860020664723282003L;
+
+ /**
+ * Constructs a new exception with the specified detail message. The cause is not initialized, and may subsequently
+ * be initialized by a call to {@link #initCause(Throwable)}.
+ *
+ * @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method)
+ */
+ public BlobStoreException(String message)
+ {
+ super(message);
+ }
+
+ /**
+ * Constructs a new exception with the specified detail message and cause.
+ *
+ * @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method)
+ * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). A null value
+ * is permitted, and indicates that the cause is nonexistent or unknown
+ */
+ public BlobStoreException(String message, Throwable cause)
+ {
+ super(message, cause);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreManager.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreManager.java
new file mode 100644
index 0000000000..2ac85ea21f
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStoreManager.java
@@ -0,0 +1,43 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import org.xwiki.component.annotation.Role;
+import org.xwiki.stability.Unstable;
+
+/**
+ * Blob store manager for getting or creating blob stores.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Role
+@Unstable
+public interface BlobStoreManager
+{
+ /**
+ * Get or create the store with the given name.
+ *
+ * @param name the name of the store. It must be a valid path in the configured blob store
+ * @return the blob store with the given name
+ * @throws BlobStoreException when there was an error initializing the blob store
+ */
+ BlobStore getBlobStore(String name) throws BlobStoreException;
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteCondition.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteCondition.java
new file mode 100644
index 0000000000..25f87d800f
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteCondition.java
@@ -0,0 +1,41 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import org.xwiki.stability.Unstable;
+
+/**
+ * Base interface for conditions that must be met when writing to a blob.
+ * This allows for atomic operations by specifying preconditions that must be satisfied
+ * before the write operation proceeds.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Unstable
+public interface WriteCondition
+{
+ /**
+ * Get a human-readable description of this condition.
+ *
+ * @return a description of the condition
+ */
+ String getDescription();
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteConditionFailedException.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteConditionFailedException.java
new file mode 100644
index 0000000000..cccffd8eaf
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/WriteConditionFailedException.java
@@ -0,0 +1,92 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.xwiki.stability.Unstable;
+
+/**
+ * Exception thrown when a write condition is not satisfied.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Unstable
+public class WriteConditionFailedException extends BlobStoreException
+{
+ private final List conditions;
+
+ private final BlobPath blobPath;
+
+ /**
+ * Constructor.
+ *
+ * @param blobPath the path of the blob for which the conditions failed
+ * @param conditions the conditions that were not satisfied
+ */
+ public WriteConditionFailedException(BlobPath blobPath, List conditions)
+ {
+ super(formatWriteConditionError(blobPath, conditions));
+ this.blobPath = blobPath;
+ this.conditions = List.copyOf(conditions);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param blobPath the path of the blob for which the conditions failed
+ * @param conditions the conditions that were not satisfied
+ * @param cause the underlying cause
+ */
+ public WriteConditionFailedException(BlobPath blobPath, List conditions, Throwable cause)
+ {
+ super(formatWriteConditionError(blobPath, conditions), cause);
+ this.blobPath = blobPath;
+ this.conditions = List.copyOf(conditions);
+ }
+
+ /**
+ * Get the blob path for which the conditions failed.
+ *
+ * @return the blob path
+ */
+ public BlobPath getBlobPath()
+ {
+ return this.blobPath;
+ }
+
+ /**
+ * Get the conditions that were not satisfied.
+ *
+ * @return the write conditions
+ */
+ public List getConditions()
+ {
+ return this.conditions;
+ }
+
+ private static String formatWriteConditionError(BlobPath blobPath, List conditions)
+ {
+ return "Write conditions failed for blob " + blobPath + ": "
+ + conditions.stream().map(WriteCondition::getDescription).collect(Collectors.joining(", "));
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/AbstractBlob.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/AbstractBlob.java
new file mode 100644
index 0000000000..ee794b21cc
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/AbstractBlob.java
@@ -0,0 +1,78 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.commons.io.IOUtils;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.WriteCondition;
+
+/**
+ * Abstract base class for {@link Blob} implementations.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public abstract class AbstractBlob implements Blob
+{
+ protected final BlobStore blobStore;
+
+ protected final BlobPath blobPath;
+
+ protected AbstractBlob(BlobStore store, BlobPath blobPath)
+ {
+ this.blobStore = store;
+ this.blobPath = blobPath;
+ }
+
+ @Override
+ public BlobStore getStore()
+ {
+ return this.blobStore;
+ }
+
+ @Override
+ public BlobPath getPath()
+ {
+ return this.blobPath;
+ }
+
+ @Override
+ public void writeFromStream(InputStream inputStream, WriteCondition... condition) throws BlobStoreException
+ {
+ try (OutputStream outputStream = this.getOutputStream(condition)) {
+ IOUtils.copy(inputStream, outputStream);
+ } catch (IOException e) {
+ if (e.getCause() instanceof BlobStoreException blobStoreException) {
+ throw blobStoreException;
+ }
+
+ throw new BlobStoreException("Error writing from InputStream to blob.", e);
+ } finally {
+ IOUtils.closeQuietly(inputStream);
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/BlobStoreConfiguration.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/BlobStoreConfiguration.java
new file mode 100644
index 0000000000..58e415374c
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/BlobStoreConfiguration.java
@@ -0,0 +1,62 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.inject.Singleton;
+
+import org.xwiki.component.annotation.Component;
+import org.xwiki.configuration.ConfigurationSource;
+
+/**
+ * Configuration for the Blob Store.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = BlobStoreConfiguration.class)
+@Singleton
+public class BlobStoreConfiguration
+{
+ private static final String FILESYSTEM_STORE_HINT = "filesystem";
+
+ @Inject
+ @Named("xwikiproperties")
+ private ConfigurationSource configurationSource;
+
+ /**
+ * @return the hint for the blob store type to use, e.g., "filesystem" or "s3". This is used to determine which blob
+ * store manager to use when creating a new blob store.
+ */
+ public String getStoreHint()
+ {
+ return this.configurationSource.getProperty("store.blobStoreHint", FILESYSTEM_STORE_HINT);
+ }
+
+ /**
+ * @return the hint for the blob store from which data should be migrated when there is no data in the current blob
+ * store. This can be used, e.g., to migrate from the filesystem blob store to an S3 blob store.
+ */
+ public String getMigrationStoreHint()
+ {
+ return this.configurationSource.getProperty("store.blobMigrationStoreHint", FILESYSTEM_STORE_HINT);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/DefaultBlobStoreManager.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/DefaultBlobStoreManager.java
new file mode 100644
index 0000000000..2fdac25cdc
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/internal/DefaultBlobStoreManager.java
@@ -0,0 +1,118 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.lang.reflect.UndeclaredThrowableException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import org.apache.commons.lang3.function.Failable;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.component.manager.ComponentLifecycleException;
+import org.xwiki.component.manager.ComponentLookupException;
+import org.xwiki.component.manager.ComponentManager;
+import org.xwiki.component.phase.Disposable;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.BlobStoreManager;
+
+/**
+ * Default implementation of {@link BlobStoreManager} that retrieves blob stores based on the name and the
+ * configured store hint.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component
+@Singleton
+public class DefaultBlobStoreManager implements BlobStoreManager, Disposable
+{
+ @Inject
+ private BlobStoreConfiguration configuration;
+
+ @Inject
+ private ComponentManager componentManager;
+
+ private final Map blobStores = new ConcurrentHashMap<>();
+
+ @Override
+ public BlobStore getBlobStore(String name) throws BlobStoreException
+ {
+ try {
+ return this.blobStores.computeIfAbsent(name,
+ key -> {
+ try {
+ return getAndMaybeMigrateStore(name);
+ } catch (ComponentLookupException | BlobStoreException e) {
+ throw Failable.rethrow(e);
+ }
+ });
+ } catch (UndeclaredThrowableException e) {
+ if (e.getUndeclaredThrowable() instanceof BlobStoreException blobStoreException) {
+ throw blobStoreException;
+ }
+ throw new BlobStoreException("Failed to get or create blob store with name [" + name + "]",
+ e.getUndeclaredThrowable());
+ }
+ }
+
+ private BlobStore getAndMaybeMigrateStore(String name) throws ComponentLookupException, BlobStoreException
+ {
+ String storeHint = this.configuration.getStoreHint();
+ String migrationStoreHint = this.configuration.getMigrationStoreHint();
+ BlobStore blobStore = getBlobStore(name, storeHint);
+
+ if (migrationStoreHint != null && !migrationStoreHint.equals(storeHint)
+ && blobStore.isEmptyDirectory(BlobPath.ROOT))
+ {
+ BlobStore migrationStore = getBlobStore(name, migrationStoreHint);
+ blobStore.moveDirectory(migrationStore, BlobPath.ROOT, BlobPath.ROOT);
+ }
+
+ return blobStore;
+ }
+
+ private BlobStore getBlobStore(String name, String storeHint) throws ComponentLookupException, BlobStoreException
+ {
+ BlobStoreManager blobStoreManager;
+ // TODO: re-consider this design.
+ String specificHint = storeHint + "/" + name;
+ if (this.componentManager.hasComponent(BlobStoreManager.class, specificHint)) {
+ blobStoreManager = this.componentManager.getInstance(BlobStoreManager.class, specificHint);
+ } else {
+ blobStoreManager = this.componentManager.getInstance(BlobStoreManager.class, storeHint);
+ }
+ return blobStoreManager.getBlobStore(name);
+ }
+
+ @Override
+ public void dispose() throws ComponentLifecycleException
+ {
+ for (BlobStore blobStore : this.blobStores.values()) {
+ if (blobStore instanceof Disposable disposableStore) {
+ disposableStore.dispose();
+ }
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/resources/META-INF/components.txt b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/resources/META-INF/components.txt
new file mode 100644
index 0000000000..a04d2f4118
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/resources/META-INF/components.txt
@@ -0,0 +1,2 @@
+org.xwiki.store.blob.internal.BlobStoreConfiguration
+org.xwiki.store.blob.internal.DefaultBlobStoreManager
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/AbstractBlobStoreTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/AbstractBlobStoreTest.java
new file mode 100644
index 0000000000..e79fda1a7a
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/AbstractBlobStoreTest.java
@@ -0,0 +1,189 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link AbstractBlobStore}.
+ *
+ * @version $Id$
+ */
+@ExtendWith(MockitoExtension.class)
+class AbstractBlobStoreTest
+{
+ private static final BlobPath SOURCE_PATH = BlobPath.from("source/dir");
+
+ private static final BlobPath TARGET_PATH = BlobPath.from("target/dir");
+
+ @Mock
+ private BlobStore otherStore;
+
+ private AbstractBlobStore blobStore;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.blobStore = mock(AbstractBlobStore.class, Mockito.CALLS_REAL_METHODS);
+ }
+
+ @Test
+ void getName()
+ {
+ String expectedName = "testStore";
+ this.blobStore.name = expectedName;
+ assertEquals(expectedName, this.blobStore.getName());
+ }
+
+ @Test
+ void moveDirectoryWithinSameStore() throws Exception
+ {
+ Blob blob1 = mock();
+ BlobPath blob1Path = BlobPath.from("source/dir/file1.txt");
+ when(blob1.getPath()).thenReturn(blob1Path);
+
+ Blob blob2 = mock();
+ BlobPath blob2Path = BlobPath.from("source/dir/subdir/file2.txt");
+ when(blob2.getPath()).thenReturn(blob2Path);
+
+ when(this.blobStore.listBlobs(SOURCE_PATH)).thenReturn(Stream.of(blob1, blob2));
+
+ this.blobStore.moveDirectory(SOURCE_PATH, TARGET_PATH);
+
+ verify(this.blobStore).moveBlob(blob1Path, BlobPath.from("target/dir/file1.txt"));
+ verify(this.blobStore).moveBlob(blob2Path, BlobPath.from("target/dir/subdir/file2.txt"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "source/dir, source/dir/subdir, Cannot move a directory to inside itself",
+ "source/dir/subdir, source/dir, Cannot move a directory to one of its ancestors",
+ "source/dir, source/dir, Cannot move a directory to inside itself",
+ "'', backup, Cannot move a directory to inside itself"
+ })
+ void moveDirectoryFailureScenarios(String source, String target, String expectedMessage) throws Exception
+ {
+ BlobPath sourcePath = source.isEmpty() ? BlobPath.ROOT : BlobPath.from(source);
+ BlobPath targetPath = BlobPath.from(target);
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class, () -> {
+ this.blobStore.moveDirectory(sourcePath, targetPath);
+ });
+
+ assertEquals(expectedMessage, exception.getMessage());
+ verify(this.blobStore, never()).listBlobs(any());
+ }
+
+ @Test
+ void moveDirectoryFromOtherStore() throws Exception
+ {
+ BlobPath blob1Path = BlobPath.from("source/dir/file1.txt");
+ Blob blob1 = mock();
+ when(blob1.getPath()).thenReturn(blob1Path);
+
+ Blob blob2 = mock();
+ BlobPath blob2Path = BlobPath.from("source/dir/nested/file2.txt");
+ when(blob2.getPath()).thenReturn(blob2Path);
+
+ when(this.otherStore.listBlobs(SOURCE_PATH)).thenReturn(Stream.of(blob1, blob2));
+
+ this.blobStore.moveDirectory(this.otherStore, SOURCE_PATH, TARGET_PATH);
+
+ verify(this.blobStore).moveBlob(this.otherStore, blob1Path, BlobPath.from("target/dir/file1.txt"));
+ verify(this.blobStore).moveBlob(this.otherStore, blob2Path, BlobPath.from("target/dir/nested/file2.txt"));
+ }
+
+ @Test
+ void moveDirectoryWithEmptyDirectory() throws Exception
+ {
+ when(this.blobStore.listBlobs(SOURCE_PATH)).thenReturn(Stream.empty());
+
+ this.blobStore.moveDirectory(SOURCE_PATH, TARGET_PATH);
+
+ verify(this.blobStore, never()).moveBlob(any(BlobPath.class), any(BlobPath.class));
+ }
+
+ @Test
+ void moveDirectoryClosesStreamOnSuccess() throws Exception
+ {
+ Stream successStream = spy(Stream.of(getSourceFileBlob()));
+ when(this.blobStore.listBlobs(SOURCE_PATH)).thenReturn(successStream);
+
+ this.blobStore.moveDirectory(SOURCE_PATH, TARGET_PATH);
+
+ verify(successStream).close();
+ }
+
+ @Test
+ void moveDirectoryClosesStreamOnExceptionInSameStore() throws Exception
+ {
+ Stream failStream = spy(Stream.of(getSourceFileBlob()));
+ when(this.blobStore.listBlobs(SOURCE_PATH)).thenReturn(failStream);
+ doThrow(new BlobStoreException("Move failed")).when(this.blobStore)
+ .moveBlob(any(BlobPath.class), any(BlobPath.class));
+
+ assertThrows(BlobStoreException.class, () -> this.blobStore.moveDirectory(SOURCE_PATH, TARGET_PATH));
+
+ verify(failStream).close();
+ }
+
+ @Test
+ void moveDirectoryClosesStreamOnExceptionInDifferentStore() throws Exception
+ {
+ Blob blob = getSourceFileBlob();
+
+ Stream crossStoreFailStream = spy(Stream.of(blob));
+ when(this.otherStore.listBlobs(SOURCE_PATH)).thenReturn(crossStoreFailStream);
+ doThrow(new BlobStoreException("Cross-store move failed")).when(this.blobStore)
+ .moveBlob(any(BlobStore.class), any(BlobPath.class), any(BlobPath.class));
+
+ assertThrows(BlobStoreException.class,
+ () -> this.blobStore.moveDirectory(this.otherStore, SOURCE_PATH, TARGET_PATH));
+
+ verify(crossStoreFailStream).close();
+ }
+
+ private static Blob getSourceFileBlob()
+ {
+ Blob blob = mock();
+ BlobPath blobPath = BlobPath.from("source/dir/file.txt");
+ when(blob.getPath()).thenReturn(blobPath);
+ return blob;
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/BlobPathTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/BlobPathTest.java
new file mode 100644
index 0000000000..1b73315b45
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/BlobPathTest.java
@@ -0,0 +1,203 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for BlobPath.
+ *
+ * @version $Id$
+ */
+class BlobPathTest
+{
+ @Test
+ void testRootIsEmptyAndEqualsOfEmpty()
+ {
+ BlobPath root = BlobPath.ROOT;
+ BlobPath empty = BlobPath.of(List.of());
+ assertNotNull(root);
+ assertEquals("", root.toString());
+ assertEquals("", root.getName());
+ assertEquals(empty, root);
+ assertEquals(empty.hashCode(), root.hashCode());
+ }
+
+ @Test
+ void testOfCreatesPathAndAccessors()
+ {
+ // Create a path and verify segments, name, and canonical string.
+ BlobPath p = BlobPath.of(List.of("a", "b", "c"));
+ assertEquals(List.of("a", "b", "c"), p.getSegments());
+ assertEquals("c", p.getName());
+ assertEquals("a/b/c", p.toString());
+ }
+
+ @Test
+ void testFromSplitsPath()
+ {
+ // From should split slash-delimited strings (ignores empty parts).
+ BlobPath p = BlobPath.from("a/b/c");
+ assertEquals(BlobPath.of(List.of("a", "b", "c")), p);
+
+ // Consecutive slashes and leading/trailing slashes are handled (no empty segments).
+ BlobPath q = BlobPath.from("/a//b/c/");
+ assertEquals(BlobPath.of(List.of("a", "b", "c")), q);
+ }
+
+ @Test
+ void testResolveAppendsSegmentsAndNoOp()
+ {
+ // Resolve should append new segments and be no-op for empty input.
+ BlobPath base = BlobPath.of(List.of("x"));
+ BlobPath resolved = base.resolve("y", "z");
+ assertEquals("x/y/z", resolved.toString());
+
+ BlobPath same = base.resolve();
+ assertSame(base, same);
+ }
+
+ @Test
+ void testGetParentBehavior()
+ {
+ // Parent of root and single-segment returns root; multi-segment returns trimmed path.
+ assertEquals(BlobPath.ROOT, BlobPath.of(List.of()).getParent());
+ assertEquals(BlobPath.ROOT, BlobPath.of(List.of("one")).getParent());
+
+ BlobPath multi = BlobPath.of(List.of("a", "b", "c"));
+ assertEquals(BlobPath.of(List.of("a", "b")), multi.getParent());
+ }
+
+ @Test
+ void testAppendSuffix()
+ {
+ // AppendSuffix should append to last segment; for empty path it creates a single segment.
+ BlobPath p = BlobPath.of(List.of("file"));
+ BlobPath suffixed = p.appendSuffix(".bak");
+ assertEquals("file.bak", suffixed.getName());
+ assertEquals("file.bak", suffixed.toString());
+
+ BlobPath rootWithSuffix = BlobPath.ROOT.appendSuffix("new");
+ assertEquals(BlobPath.of(List.of("new")), rootWithSuffix);
+
+ // blank suffix should throw
+ assertThrows(IllegalArgumentException.class, () -> p.appendSuffix(" "));
+ }
+
+ @Test
+ void testValidationNullSegmentsList()
+ {
+ // of(null) should fail with IllegalArgumentException per constructor check.
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> BlobPath.of(null));
+ assertTrue(ex.getMessage().contains("segments must not be null"));
+ }
+
+ @Test
+ void testValidationNullSegmentElement()
+ {
+ // null element in segments should be rejected with informative message.
+ List segments = Arrays.asList("ok", null);
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(segments));
+ assertTrue(ex.getMessage().contains("Segment at index 1 is null"));
+ }
+
+ @Test
+ void testValidationEmptySegment()
+ {
+ // Empty segment not allowed.
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(List.of("a", "")));
+ assertTrue(ex.getMessage().contains("Segment at index 1 is empty"));
+ }
+
+ @Test
+ void testValidationDirectoryTraversal()
+ {
+ // '.' or '..' are rejected.
+ IllegalArgumentException ex1 = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(List.of("a", ".")));
+ assertTrue(ex1.getMessage().contains("is a directory traversal"));
+
+ IllegalArgumentException ex2 = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(List.of("..")));
+ assertTrue(ex2.getMessage().contains("is a directory traversal"));
+ }
+
+ @Test
+ void testValidationIllegalSeparatorCharacters()
+ {
+ // Segments cannot contain '/' or '\'.
+ IllegalArgumentException ex1 = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(List.of("bad/seg")));
+ assertTrue(ex1.getMessage().contains("contains an illegal path separator"));
+
+ IllegalArgumentException ex2 = assertThrows(IllegalArgumentException.class,
+ () -> BlobPath.of(List.of("bad\\seg")));
+ assertTrue(ex2.getMessage().contains("contains an illegal path separator"));
+ }
+
+ @Test
+ void testFromNullThrows()
+ {
+ IllegalArgumentException illegalArgumentException =
+ assertThrows(IllegalArgumentException.class, () -> BlobPath.from(null));
+ assertTrue(illegalArgumentException.getMessage().contains("path must not be null"));
+ }
+
+ @Test
+ void testFromEmptyIsRoot()
+ {
+ // From("") should return ROOT.
+ BlobPath p = BlobPath.from("");
+ assertSame(BlobPath.ROOT, p);
+ }
+
+ @Test
+ void testResolveWithInvalidSegmentThrows()
+ {
+ // Resolve should validate new segments via constructor and therefore throw on invalid input.
+ BlobPath base = BlobPath.of(List.of("a"));
+ assertThrows(IllegalArgumentException.class, () -> base.resolve("ok", ".."));
+ }
+
+ @Test
+ void testEqualsAndHashCodeConsistency()
+ {
+ // Equal paths must be equal and have equal hash codes.
+ BlobPath a = BlobPath.of(List.of("x", "y"));
+ BlobPath b = BlobPath.of(List.of("x", "y"));
+ BlobPath c = BlobPath.of(List.of("x", "z"));
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertNotEquals(a, c);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/internal/DefaultBlobStoreManagerTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/internal/DefaultBlobStoreManagerTest.java
new file mode 100644
index 0000000000..525cba6dce
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/test/java/org/xwiki/store/blob/internal/DefaultBlobStoreManagerTest.java
@@ -0,0 +1,229 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Named;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.xwiki.component.manager.ComponentLookupException;
+import org.xwiki.component.phase.Disposable;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.BlobStoreManager;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectComponentManager;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+import org.xwiki.test.mockito.MockitoComponentManager;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link DefaultBlobStoreManager}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class DefaultBlobStoreManagerTest
+{
+ @InjectMockComponents
+ private DefaultBlobStoreManager blobStoreManager;
+
+ @MockComponent
+ private BlobStoreConfiguration configuration;
+
+ @InjectComponentManager
+ private MockitoComponentManager componentManager;
+
+ @Mock
+ private BlobStore testStore;
+
+ @Mock
+ private BlobStore specificStore;
+
+ @Mock
+ private BlobStore migrationStore;
+
+ @MockComponent
+ @Named("file")
+ private BlobStoreManager fileBlobStoreManager;
+
+ @MockComponent
+ @Named("file/specificStore")
+ private BlobStoreManager specificBlobStoreManagerMock;
+
+ @BeforeEach
+ void setup() throws BlobStoreException
+ {
+ when(this.configuration.getStoreHint()).thenReturn("file");
+ when(this.fileBlobStoreManager.getBlobStore("testStore")).thenReturn(this.testStore);
+ when(this.specificBlobStoreManagerMock.getBlobStore("specificStore")).thenReturn(this.specificStore);
+ }
+
+ @Test
+ void getBlobStoreWhenStoreExistsInCache() throws Exception
+ {
+ // Setup: Pre-populate the cache by calling getBlobStore once
+ // First call to populate cache
+ BlobStore firstResult = this.blobStoreManager.getBlobStore("testStore");
+
+ // Second call should use cache
+ BlobStore secondResult = this.blobStoreManager.getBlobStore("testStore");
+
+ assertSame(firstResult, secondResult);
+ assertSame(this.testStore, secondResult);
+ // The component lookup should only happen once due to caching
+ verify(this.fileBlobStoreManager, times(1)).getBlobStore("testStore");
+ }
+
+ @Test
+ void getBlobStoreWithSpecificHint() throws Exception
+ {
+ BlobStore result = this.blobStoreManager.getBlobStore("specificStore");
+
+ assertSame(this.specificStore, result);
+ verify(this.specificBlobStoreManagerMock).getBlobStore("specificStore");
+ }
+
+ @Test
+ void getBlobStoreWithMigrationWhenCurrentStoreIsEmpty() throws Exception
+ {
+ when(this.configuration.getMigrationStoreHint()).thenReturn("s3");
+
+ BlobStoreManager s3BlobStoreManager =
+ this.componentManager.registerMockComponent(BlobStoreManager.class, "s3");
+
+ when(s3BlobStoreManager.getBlobStore("testStore")).thenReturn(this.migrationStore);
+ when(this.testStore.isEmptyDirectory(BlobPath.ROOT)).thenReturn(true);
+
+ BlobStore result = this.blobStoreManager.getBlobStore("testStore");
+
+ assertSame(this.testStore, result);
+ verify(this.testStore).isEmptyDirectory(BlobPath.ROOT);
+ verify(this.testStore).moveDirectory(this.migrationStore, BlobPath.ROOT, BlobPath.ROOT);
+ verify(this.configuration).getMigrationStoreHint();
+ }
+
+ @Test
+ void getBlobStoreWithMigrationWhenCurrentStoreIsNotEmpty() throws Exception
+ {
+ when(this.configuration.getMigrationStoreHint()).thenReturn("s3");
+
+ BlobStoreManager s3BlobStoreManager =
+ this.componentManager.registerMockComponent(BlobStoreManager.class, "s3");
+
+ when(s3BlobStoreManager.getBlobStore("testStore")).thenReturn(this.migrationStore);
+ when(this.testStore.isEmptyDirectory(BlobPath.ROOT)).thenReturn(false);
+
+ BlobStore result = this.blobStoreManager.getBlobStore("testStore");
+
+ assertSame(this.testStore, result);
+ verify(this.testStore).isEmptyDirectory(BlobPath.ROOT);
+ verify(this.testStore, never()).moveDirectory(any(), any(), any());
+ }
+
+ @Test
+ void getBlobStoreWithSameStoreAndMigrationHint() throws Exception
+ {
+ when(this.configuration.getMigrationStoreHint()).thenReturn("file");
+
+ BlobStore result = this.blobStoreManager.getBlobStore("testStore");
+
+ assertSame(this.testStore, result);
+ verify(this.testStore, never()).isEmptyDirectory(any());
+ verify(this.testStore, never()).moveDirectory(any(), any(), any());
+ }
+
+ @Test
+ void getBlobStoreWhenNoMigrationHint() throws Exception
+ {
+ when(this.configuration.getMigrationStoreHint()).thenReturn(null);
+
+ BlobStore result = this.blobStoreManager.getBlobStore("testStore");
+
+ assertSame(this.testStore, result);
+ verify(this.configuration).getMigrationStoreHint();
+ verify(this.testStore, never()).isEmptyDirectory(any());
+ verify(this.testStore, never()).moveDirectory(any(), any(), any());
+ }
+
+ @Test
+ void getBlobStoreThrowsComponentLookupException()
+ {
+ when(this.configuration.getStoreHint()).thenReturn("foo");
+ // Don't register any component manager for "foo" hint, causing a lookup exception
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class, () -> {
+ this.blobStoreManager.getBlobStore("testStore");
+ });
+
+ assertEquals("Failed to get or create blob store with name [testStore]", exception.getMessage());
+ assertInstanceOf(ComponentLookupException.class, exception.getCause());
+ }
+
+ @Test
+ void getBlobStoreThrowsBlobStoreException() throws Exception
+ {
+ when(this.fileBlobStoreManager.getBlobStore("testStore"))
+ .thenThrow(new BlobStoreException("Store error"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class, () -> {
+ this.blobStoreManager.getBlobStore("testStore");
+ });
+
+ assertEquals("Store error", exception.getMessage());
+ }
+
+ @Test
+ void disposeWithDisposableBlobStores() throws Exception
+ {
+ // Create a mock that implements both BlobStore and Disposable
+ DisposableBlobStore disposableBlobStore = mock();
+
+ when(this.fileBlobStoreManager.getBlobStore("disposableStore")).thenReturn(disposableBlobStore);
+ when(this.fileBlobStoreManager.getBlobStore("regularStore")).thenReturn(this.testStore);
+
+ // Populate the cache with both types of stores
+ this.blobStoreManager.getBlobStore("disposableStore");
+ this.blobStoreManager.getBlobStore("regularStore");
+
+ this.blobStoreManager.dispose();
+
+ verify(disposableBlobStore).dispose();
+ }
+
+ // Helper interface to create a mock that implements both BlobStore and Disposable
+ private interface DisposableBlobStore extends BlobStore, Disposable
+ {
+ }
+}
+
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/pom.xml b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/pom.xml
new file mode 100644
index 0000000000..a74c10c889
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/pom.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 4.0.0
+
+ org.xwiki.commons
+ xwiki-commons-store-blob
+ 17.10.0-SNAPSHOT
+
+ xwiki-commons-store-blob-filesystem
+ XWiki Commons - Store - Blob - Filesystem
+ jar
+ Blob storage implementation based on the local filesystem.
+
+ 0.94
+
+
+
+ org.xwiki.commons
+ xwiki-commons-store-blob-api
+ ${project.version}
+
+
+ org.xwiki.commons
+ xwiki-commons-environment-api
+ ${project.version}
+
+
+
+ org.xwiki.commons
+ xwiki-commons-tool-test-component
+ ${project.version}
+ test
+
+
+
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlob.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlob.java
new file mode 100644
index 0000000000..f6d3b2a487
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlob.java
@@ -0,0 +1,129 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.WriteCondition;
+import org.xwiki.store.blob.WriteConditionFailedException;
+
+/**
+ * A {@link Blob} implementation that represents a file in the file system.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public class FileSystemBlob extends AbstractBlob
+{
+ private final Path absolutePath;
+
+ /**
+ * Creates a new FileSystemBlob.
+ *
+ * @param blobPath the path of the blob inside the store
+ * @param absolutePath the absolute file system path to the blob
+ * @param store the blob store where this blob is stored
+ */
+ public FileSystemBlob(BlobPath blobPath, Path absolutePath, FileSystemBlobStore store)
+ {
+ super(store, blobPath);
+ this.absolutePath = absolutePath;
+ }
+
+ @Override
+ public boolean exists()
+ {
+ return Files.exists(this.absolutePath, LinkOption.NOFOLLOW_LINKS);
+ }
+
+ @Override
+ public long getSize() throws BlobStoreException
+ {
+ try {
+ return Files.exists(this.absolutePath, LinkOption.NOFOLLOW_LINKS)
+ ? Files.size(this.absolutePath)
+ : -1;
+ } catch (IOException e) {
+ throw new BlobStoreException("Error getting file size.", e);
+ }
+ }
+
+ @Override
+ public OutputStream getOutputStream(WriteCondition... conditions) throws BlobStoreException
+ {
+ NoSuchFileException lastNoSuchFileException = null;
+ for (int attempt = 0; attempt < FileSystemBlobStore.NUM_ATTEMPTS; ++attempt) {
+ try {
+ // Ensure the parent directory exists before creating the output stream.
+ ((FileSystemBlobStore) this.blobStore).createParents(this.absolutePath);
+
+ if (Arrays.stream(conditions).anyMatch(BlobDoesNotExistCondition.class::isInstance)) {
+ // Use CREATE_NEW to ensure atomic create-only behavior
+ return Files.newOutputStream(this.absolutePath,
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+ } else {
+ // Default behavior - create or overwrite
+ return Files.newOutputStream(this.absolutePath);
+ }
+ } catch (FileAlreadyExistsException e) {
+ throw new WriteConditionFailedException(this.blobPath, Arrays.asList(conditions), e);
+ } catch (NoSuchFileException e) {
+ // This can happen if the parent directory was deleted between our check and the attempt to create the
+ // output stream. Loop around and try again. Remember the exception in case we fail multiple times.
+ lastNoSuchFileException = e;
+ } catch (IOException e) {
+ // Something went wrong creating the output stream. Attempt to clean up any parent directories we may
+ // have created.
+ ((FileSystemBlobStore) this.blobStore).cleanUpParents(this.absolutePath);
+ throw new BlobStoreException("Error getting output stream.", e);
+ }
+ }
+
+ // We failed multiple times due to missing parent directories. Give up.
+ throw new BlobStoreException("Error creating parent directories for output stream in %s attempts."
+ .formatted(FileSystemBlobStore.NUM_ATTEMPTS), lastNoSuchFileException);
+ }
+
+ @Override
+ public InputStream getStream() throws BlobStoreException
+ {
+ try {
+ return Files.newInputStream(this.absolutePath, LinkOption.NOFOLLOW_LINKS);
+ } catch (NoSuchFileException e) {
+ throw new BlobNotFoundException(this.blobPath, e);
+ } catch (IOException e) {
+ throw new BlobStoreException("Error getting input stream.", e);
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStore.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStore.java
new file mode 100644
index 0000000000..64a18826d6
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStore.java
@@ -0,0 +1,336 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.file.PathUtils;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.xwiki.store.blob.AbstractBlobStore;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobAlreadyExistsException;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+
+/**
+ * A {@link BlobStore} implementation that stores blobs in the file system.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public class FileSystemBlobStore extends AbstractBlobStore
+{
+ /**
+ * Number of attempts to make when trying to create parent directories for a blob operation.
+ */
+ static final int NUM_ATTEMPTS = 5;
+
+ private static final String SOURCE_AND_TARGET_SAME_ERROR = "source and target paths are the same";
+
+ private final Path basePath;
+
+ /**
+ * Creates a new FileSystemBlobStore with the given name and base path.
+ *
+ * @param name the name of the blob store
+ * @param basePath the base path in the file system where blobs are stored
+ */
+ public FileSystemBlobStore(String name, Path basePath)
+ {
+ super(name);
+ this.basePath = basePath;
+ }
+
+ @Override
+ public Blob getBlob(BlobPath path) throws BlobStoreException
+ {
+ Path blobFsPath = getBlobFilePath(path);
+ return new FileSystemBlob(path, blobFsPath, this);
+ }
+
+ /**
+ * Get the absolute file system path for the given BlobPath. This is meant for internal use only.
+ *
+ * @param blobPath the BlobPath to get the file system path for
+ * @return the absolute file system path for the given BlobPath
+ */
+ public Path getBlobFilePath(BlobPath blobPath)
+ {
+ Path currentPath = this.basePath;
+
+ // Append each segment of the BlobPath to the base path.
+ for (String segment : blobPath.getSegments()) {
+ currentPath = currentPath.resolve(segment);
+ }
+
+ return currentPath;
+ }
+
+ @Override
+ public Stream listBlobs(BlobPath path) throws BlobStoreException
+ {
+ Path absolutePath = getBlobFilePath(path);
+ if (!Files.exists(absolutePath) || !Files.isDirectory(absolutePath)) {
+ return Stream.empty();
+ } else {
+ // List files recursively, ignoring directories.
+ try {
+ Path normalizedAbsolutePath = absolutePath.normalize();
+ return Files.walk(normalizedAbsolutePath)
+ .filter(Files::isRegularFile)
+ .map(p -> {
+ // Compute relative path by subtracting the base path
+ Path normalizedPath = p.normalize();
+ if (!normalizedPath.startsWith(normalizedAbsolutePath)) {
+ // This should never happen, but just in case...
+ throw new IllegalStateException(
+ "Found a file outside the expected directory: " + normalizedPath);
+ }
+ Path relativePath = normalizedAbsolutePath.relativize(normalizedPath);
+
+ // Convert relative path segments to list
+ List segments = new ArrayList<>(path.getSegments());
+ for (Path segment : relativePath) {
+ segments.add(segment.toString());
+ }
+
+ return new FileSystemBlob(BlobPath.of(segments), p, this);
+ });
+ } catch (IOException e) {
+ throw new BlobStoreException("Failed to list blobs in directory: " + absolutePath, e);
+ }
+ }
+ }
+
+ @Override
+ public Blob copyBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourcePath.equals(targetPath)) {
+ throw new BlobStoreException(SOURCE_AND_TARGET_SAME_ERROR);
+ }
+
+ Path absoluteSourcePath = getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, false);
+
+ return getBlob(targetPath);
+ }
+
+ @Override
+ public Blob copyBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourceStore instanceof FileSystemBlobStore fileSystemBlobStore) {
+ Path absoluteSourcePath = fileSystemBlobStore.getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, false);
+ } else {
+ // For cross-store copies, use streaming approach.
+ try (var inputStream = sourceStore.getBlob(sourcePath).getStream()) {
+ Blob targetBlob = getBlob(targetPath);
+ targetBlob.writeFromStream(inputStream, BlobDoesNotExistCondition.INSTANCE);
+ } catch (BlobStoreException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BlobStoreException("Reading source blob failed", e);
+ }
+ }
+
+ return getBlob(targetPath);
+ }
+
+ @Override
+ public void moveDirectory(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourcePath.equals(targetPath)) {
+ throw new BlobStoreException(SOURCE_AND_TARGET_SAME_ERROR);
+ }
+
+ Path absoluteSourcePath = getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, true);
+ }
+
+ @Override
+ public void moveDirectory(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourceStore instanceof FileSystemBlobStore fileSystemBlobStore) {
+ Path absoluteSourcePath = fileSystemBlobStore.getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, true);
+ } else {
+ super.moveDirectory(sourceStore, sourcePath, targetPath);
+ }
+ }
+
+ @Override
+ public boolean isEmptyDirectory(BlobPath path) throws BlobStoreException
+ {
+ try (Stream stream = listBlobs(path)) {
+ return stream.findFirst().isEmpty();
+ }
+ }
+
+ private void transferBlobInternal(BlobPath sourcePath, BlobPath targetPath, Path absoluteSourcePath, boolean move)
+ throws BlobStoreException
+ {
+ Path absoluteTargetPath = getBlobFilePath(targetPath);
+
+ String operation = move ? "move" : "copy";
+
+ NoSuchFileException lastNoSuchFileException = null;
+
+ for (int attempt = 0; attempt < NUM_ATTEMPTS; ++attempt) {
+ try {
+ // Ensure parent directory exists
+ createParents(absoluteTargetPath);
+
+ // Use atomic operation - this will fail if source doesn't exist or target exists
+ if (move) {
+ Files.move(absoluteSourcePath, absoluteTargetPath);
+ cleanUpParents(absoluteSourcePath);
+ } else {
+ Files.copy(absoluteSourcePath, absoluteTargetPath);
+ }
+
+ return;
+ } catch (NoSuchFileException e) {
+ // If the source file doesn't exist, we can't continue. If the parent directory of the target
+ // file doesn't exist, loop around and try again.
+ if (!Files.exists(absoluteSourcePath)) {
+ cleanUpParents(absoluteTargetPath);
+ throw new BlobNotFoundException(sourcePath, e);
+ }
+ lastNoSuchFileException = e;
+ } catch (FileAlreadyExistsException e) {
+ throw new BlobAlreadyExistsException(targetPath, e);
+ } catch (IOException e) {
+ cleanUpParents(absoluteTargetPath);
+ throw new BlobStoreException(operation + " blob failed", e);
+ }
+ }
+
+ cleanUpParents(absoluteTargetPath);
+ throw new BlobStoreException("%s blob failed after %d attempts".formatted(operation, NUM_ATTEMPTS),
+ lastNoSuchFileException);
+ }
+
+ void createParents(Path absoluteTargetPath) throws IOException
+ {
+ Path targetParent = absoluteTargetPath.getParent();
+ if (targetParent != null) {
+ Files.createDirectories(targetParent);
+ }
+ }
+
+ @Override
+ public Blob moveBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourcePath.equals(targetPath)) {
+ throw new BlobStoreException(SOURCE_AND_TARGET_SAME_ERROR);
+ }
+
+ Path absoluteSourcePath = getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, true);
+
+ return getBlob(targetPath);
+ }
+
+ @Override
+ public Blob moveBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ if (sourceStore instanceof FileSystemBlobStore fileSystemBlobStore) {
+ Path absoluteSourcePath = fileSystemBlobStore.getBlobFilePath(sourcePath);
+ transferBlobInternal(sourcePath, targetPath, absoluteSourcePath, true);
+ return getBlob(targetPath);
+ } else {
+ // For cross-store moves, use copy + delete approach
+ Blob targetBlob = copyBlob(sourceStore, sourcePath, targetPath);
+ sourceStore.deleteBlob(sourcePath);
+ return targetBlob;
+ }
+ }
+
+ @Override
+ public void deleteBlob(BlobPath path) throws BlobStoreException
+ {
+ try {
+ Path fileSystemPath = getBlobFilePath(path);
+ Files.deleteIfExists(fileSystemPath);
+ // Delete parent directories up to basePath.
+ cleanUpParents(fileSystemPath);
+ } catch (IOException e) {
+ throw new BlobStoreException("delete blob failed", e);
+ }
+ }
+
+ void cleanUpParents(Path fileSystemPath)
+ {
+ try {
+ for (Path parentPath = fileSystemPath.getParent(); parentPath != null && !this.basePath.equals(parentPath)
+ && Files.isDirectory(parentPath) && PathUtils.isEmptyDirectory(parentPath);
+ parentPath = parentPath.getParent()) {
+ Files.deleteIfExists(parentPath);
+ }
+ } catch (IOException e) {
+ // Ignore errors when cleaning up parent directories.
+ }
+ }
+
+ @Override
+ public void deleteBlobs(BlobPath path) throws BlobStoreException
+ {
+ try {
+ Path absolutePath = getBlobFilePath(path);
+ if (Files.exists(absolutePath) && Files.isDirectory(absolutePath)) {
+ PathUtils.deleteDirectory(absolutePath);
+ cleanUpParents(absolutePath);
+ }
+ } catch (IOException e) {
+ throw new BlobStoreException("delete blobs failed", e);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof FileSystemBlobStore blobStore)) {
+ return false;
+ }
+
+ return new EqualsBuilder().append(this.basePath, blobStore.basePath).isEquals();
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return new HashCodeBuilder(17, 37).append(this.basePath).toHashCode();
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManager.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManager.java
new file mode 100644
index 0000000000..d2f10bafa5
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManager.java
@@ -0,0 +1,59 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.nio.file.Path;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.inject.Singleton;
+
+import org.apache.commons.lang3.StringUtils;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.environment.Environment;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.BlobStoreManager;
+
+/**
+ * Blob store manager for the file-system-based blob store.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component
+@Singleton
+@Named("filesystem")
+public class FileSystemBlobStoreManager implements BlobStoreManager
+{
+ @Inject
+ private Environment environment;
+
+ @Override
+ public BlobStore getBlobStore(String name) throws BlobStoreException
+ {
+ if (StringUtils.isBlank(name)) {
+ throw new BlobStoreException("The blob store name must not be null or empty");
+ }
+
+ Path basePath = this.environment.getPermanentDirectory().toPath().resolve(name);
+ return new FileSystemBlobStore(name, basePath);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/resources/META-INF/components.txt b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/resources/META-INF/components.txt
new file mode 100644
index 0000000000..9ea9164406
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/main/resources/META-INF/components.txt
@@ -0,0 +1 @@
+org.xwiki.store.blob.internal.FileSystemBlobStoreManager
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManagerTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManagerTest.java
new file mode 100644
index 0000000000..914f87b959
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreManagerTest.java
@@ -0,0 +1,84 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.File;
+import java.nio.file.Path;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.xwiki.environment.Environment;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link FileSystemBlobStoreManager}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class FileSystemBlobStoreManagerTest
+{
+ @InjectMockComponents
+ private FileSystemBlobStoreManager blobStoreManager;
+
+ @MockComponent
+ private Environment environment;
+
+ @ParameterizedTest
+ @CsvSource({
+ "testStore, /tmp/xwiki/testStore",
+ "test-store_with.special/chars, /tmp/xwiki/test-store_with.special/chars"
+ })
+ void getBlobStore(String storeName, String expectedPath) throws Exception
+ {
+ File permanentDirectory = new File("/tmp/xwiki");
+ when(this.environment.getPermanentDirectory()).thenReturn(permanentDirectory);
+
+ BlobStore blobStore = this.blobStoreManager.getBlobStore(storeName);
+
+ assertNotNull(blobStore);
+ assertEquals(storeName, blobStore.getName());
+ assertInstanceOf(FileSystemBlobStore.class, blobStore);
+ assertEquals(Path.of(expectedPath),
+ ((FileSystemBlobStore) blobStore).getBlobFilePath(BlobPath.ROOT));
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void getBlobStoreWithNullName(String name)
+ {
+ File permanentDirectory = new File("/tmp/xwiki");
+ when(this.environment.getPermanentDirectory()).thenReturn(permanentDirectory);
+
+ assertThrows(BlobStoreException.class, () -> this.blobStoreManager.getBlobStore(name));
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreTest.java
new file mode 100644
index 0000000000..3836149269
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobStoreTest.java
@@ -0,0 +1,949 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.file.PathUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobAlreadyExistsException;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.test.junit5.XWikiTempDir;
+import org.xwiki.test.junit5.XWikiTempDirExtension;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link FileSystemBlobStore}.
+ *
+ * @version $Id$
+ */
+@SuppressWarnings("checkstyle:MultipleStringLiterals")
+@ExtendWith({ XWikiTempDirExtension.class, MockitoExtension.class })
+class FileSystemBlobStoreTest extends XWikiTempDirExtension
+{
+ @XWikiTempDir
+ private File tmpDir;
+
+ @Mock
+ private BlobStore mockSourceStore;
+
+ @Mock
+ private Blob mockSourceBlob;
+
+ private FileSystemBlobStore blobStore;
+
+ private Path basePath;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.basePath = this.tmpDir.toPath();
+ this.blobStore = new FileSystemBlobStore("testStore", this.basePath);
+ }
+
+ @Test
+ void constructor()
+ {
+ FileSystemBlobStore store = new FileSystemBlobStore("testName", this.basePath);
+ assertEquals("testName", store.getName());
+ }
+
+ @Test
+ void getBlobFilePath()
+ {
+ BlobPath blobPath = BlobPath.of(List.of("dir1", "dir2", "file.txt"));
+ Path expected = this.basePath.resolve("dir1").resolve("dir2").resolve("file.txt");
+
+ Path actual = this.blobStore.getBlobFilePath(blobPath);
+
+ assertEquals(expected, actual);
+ }
+
+ @Test
+ void getBlobFilePathWithSingleSegment()
+ {
+ BlobPath blobPath = BlobPath.of(List.of("file.txt"));
+ Path expected = this.basePath.resolve("file.txt");
+
+ Path actual = this.blobStore.getBlobFilePath(blobPath);
+
+ assertEquals(expected, actual);
+ }
+
+ @Test
+ void getBlob() throws BlobStoreException
+ {
+ BlobPath blobPath = BlobPath.of(List.of("test.txt"));
+
+ Blob blob = this.blobStore.getBlob(blobPath);
+
+ assertInstanceOf(FileSystemBlob.class, blob);
+ assertEquals(blobPath, blob.getPath());
+ }
+
+ @Test
+ void listBlobsInNonExistentDirectory() throws BlobStoreException
+ {
+ BlobPath path = BlobPath.of(List.of("nonexistent"));
+
+ try (Stream blobs = this.blobStore.listBlobs(path)) {
+ assertEquals(0, blobs.count());
+ }
+ }
+
+ @Test
+ void listBlobsInEmptyDirectory() throws IOException, BlobStoreException
+ {
+ Path dir = this.basePath.resolve("empty");
+ Files.createDirectories(dir);
+ BlobPath path = BlobPath.of(List.of("empty"));
+
+ try (Stream blobs = this.blobStore.listBlobs(path)) {
+ assertEquals(0, blobs.count());
+ }
+ }
+
+ @Test
+ void listBlobsWithFiles() throws IOException, BlobStoreException
+ {
+ Path dir = this.basePath.resolve("testdir");
+ Files.createDirectories(dir);
+ Files.createFile(dir.resolve("file1.txt"));
+ Files.createFile(dir.resolve("file2.txt"));
+
+ Path subdir = dir.resolve("subdir");
+ Files.createDirectories(subdir);
+ Files.createFile(subdir.resolve("file3.txt"));
+
+ BlobPath path = BlobPath.of(List.of("testdir"));
+
+ try (Stream blobs = this.blobStore.listBlobs(path)) {
+ List blobList = blobs.toList();
+ assertEquals(3, blobList.size());
+
+ // Verify blob paths
+ List filenames = blobList.stream()
+ .map(blob -> blob.getPath().toString())
+ .sorted()
+ .toList();
+ assertEquals(List.of("testdir/file1.txt", "testdir/file2.txt", "testdir/subdir/file3.txt"), filenames);
+ }
+ }
+
+ @Test
+ void copyBlobSameFile()
+ {
+ BlobPath path = BlobPath.of(List.of("test.txt"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blobStore.copyBlob(path, path));
+
+ assertEquals("source and target paths are the same", exception.getMessage());
+ }
+
+ @Test
+ void copyBlobSourceNotFound()
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("nonexistent.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target.txt"));
+
+ BlobNotFoundException blobNotFoundException = assertThrows(BlobNotFoundException.class,
+ () -> this.blobStore.copyBlob(sourcePath, targetPath));
+ assertEquals(sourcePath, blobNotFoundException.getBlobPath());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'file.txt', 'copy_of_file.txt'",
+ "'dir1/dir2/file.txt', 'dir1/dir2/copy_of_file.txt'",
+ "'dir1/file.txt', 'dir1/copy_of_file.txt'"
+ })
+ void copyBlobSuccess(String source, String target) throws IOException, BlobStoreException
+ {
+ // Create source file
+ Path sourceFile = this.basePath.resolve(source);
+ Files.createDirectories(sourceFile.getParent());
+ Files.createFile(sourceFile);
+ Files.writeString(sourceFile, "test content");
+
+ BlobPath sourcePath = BlobPath.from(source);
+ BlobPath targetPath = BlobPath.from(target);
+
+ Blob result = this.blobStore.copyBlob(sourcePath, targetPath);
+
+ // Verify both files exist
+ assertTrue(Files.exists(sourceFile));
+ Path targetFile = this.basePath.resolve(target);
+ assertTrue(Files.exists(targetFile));
+
+ // Verify content
+ assertEquals("test content", Files.readString(targetFile));
+ assertEquals(targetPath, result.getPath());
+ }
+
+ @Test
+ void copyBlobTargetAlreadyExists() throws IOException
+ {
+ // Create both source and target files
+ Path sourceFile = this.basePath.resolve("source.txt");
+ Path targetFile = this.basePath.resolve("target.txt");
+ Files.createFile(sourceFile);
+ Files.createFile(targetFile);
+
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target.txt"));
+
+ assertThrows(BlobAlreadyExistsException.class,
+ () -> this.blobStore.copyBlob(sourcePath, targetPath));
+ }
+
+ @Test
+ void copyBlobTargetDirectoryExists() throws IOException, BlobStoreException
+ {
+ // Create source file and target directory structure
+ Path sourceFile = this.basePath.resolve("docs/manual.pdf");
+ Files.createDirectories(sourceFile.getParent());
+ Files.createFile(sourceFile);
+ Files.writeString(sourceFile, "PDF content");
+
+ // Create target directory but not the file
+ Path targetDir = this.basePath.resolve("backup/docs");
+ Files.createDirectories(targetDir);
+
+ BlobPath sourcePath = BlobPath.of(List.of("docs", "manual.pdf"));
+ BlobPath targetPath = BlobPath.of(List.of("backup", "docs", "manual.pdf"));
+
+ Blob result = this.blobStore.copyBlob(sourcePath, targetPath);
+
+ // Verify copy succeeded even though parent directories existed
+ Path targetFile = this.basePath.resolve("backup/docs/manual.pdf");
+ assertTrue(Files.exists(targetFile));
+ assertEquals("PDF content", Files.readString(targetFile));
+ assertEquals(targetPath, result.getPath());
+ }
+
+ @Test
+ void copyBlobFromSameStore() throws IOException, BlobStoreException
+ {
+ // Create source file
+ Path sourceFile = this.basePath.resolve("source.txt");
+ Files.createFile(sourceFile);
+ Files.writeString(sourceFile, "test content");
+
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target.txt"));
+
+ FileSystemBlobStore sourceStore = new FileSystemBlobStore("source", this.basePath);
+
+ Blob result = this.blobStore.copyBlob(sourceStore, sourcePath, targetPath);
+
+ // Verify target file exists
+ Path targetFile = this.basePath.resolve("target.txt");
+ assertTrue(Files.exists(targetFile));
+ assertEquals("test content", Files.readString(targetFile));
+ assertEquals(targetPath, result.getPath());
+ }
+
+ @Test
+ void copyBlobFromDifferentStore() throws Exception
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target.txt"));
+ String expectedContent = "test content for copy operation";
+
+ when(this.mockSourceStore.getBlob(sourcePath)).thenReturn(this.mockSourceBlob);
+ when(this.mockSourceBlob.getStream()).thenReturn(new ByteArrayInputStream(expectedContent.getBytes()));
+
+ Blob result = this.blobStore.copyBlob(this.mockSourceStore, sourcePath, targetPath);
+
+ assertEquals(targetPath, result.getPath());
+ verify(this.mockSourceStore).getBlob(sourcePath);
+ verify(this.mockSourceBlob).getStream();
+ // Verify that no delete method was called on the source store (this is copy, not move)
+ verify(this.mockSourceStore, never()).deleteBlob(any());
+ verify(this.mockSourceStore, never()).deleteBlobs(any());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'temp/file1.txt', 'archive/file1.txt', 'Temporary content 1'",
+ "'data/logs/app.log', 'backup/logs/2023/app.log', 'Log entry: Application started'",
+ "'images/photos/vacation.jpg', 'gallery/2023/vacation.jpg', 'JPEG binary data'"
+ })
+ void moveBlobWithNestedDirectories(String sourcePath, String targetPath, String content)
+ throws IOException, BlobStoreException
+ {
+ // Create source file with nested directory structure
+ BlobPath source = BlobPath.from(sourcePath);
+ Path sourceFile = this.basePath.resolve(sourcePath);
+ Files.createDirectories(sourceFile.getParent());
+ Files.createFile(sourceFile);
+ Files.writeString(sourceFile, content);
+
+ BlobPath target = BlobPath.from(targetPath);
+
+ Blob result = this.blobStore.moveBlob(source, target);
+
+ // Verify source file is gone
+ assertFalse(Files.exists(sourceFile), "Source file should be deleted: " + sourcePath);
+
+ // Verify target file exists and has correct content
+ Path targetFile = this.basePath.resolve(targetPath);
+ assertTrue(Files.exists(targetFile), "Target file should exist: " + targetPath);
+ assertEquals(content, Files.readString(targetFile), "Content should match");
+
+ // Verify returned blob
+ assertEquals(target, result.getPath());
+ }
+
+ @Test
+ void moveBlobWithDirectoryCleanup() throws IOException, BlobStoreException
+ {
+ // Create multiple files in nested directories
+ String[] filePaths = {
+ "temp/data/file1.txt",
+ "temp/data/file2.txt",
+ "temp/logs/app.log"
+ };
+
+ // Create all files
+ for (String filePath : filePaths) {
+ Path file = this.basePath.resolve(filePath);
+ Files.createDirectories(file.getParent());
+ Files.createFile(file);
+ Files.writeString(file, "Content for " + filePath);
+ }
+
+ // Move one file - directories should not be cleaned up yet
+ BlobPath sourcePath1 = BlobPath.of(List.of("temp", "data", "file1.txt"));
+ BlobPath targetPath1 = BlobPath.of(List.of("archive", "file1.txt"));
+
+ this.blobStore.moveBlob(sourcePath1, targetPath1);
+
+ // Verify first file moved
+ assertFalse(Files.exists(this.basePath.resolve("temp/data/file1.txt")));
+ assertTrue(Files.exists(this.basePath.resolve("archive/file1.txt")));
+
+ // Verify other files and directories still exist
+ assertTrue(Files.exists(this.basePath.resolve("temp/data/file2.txt")));
+ assertTrue(Files.exists(this.basePath.resolve("temp/logs/app.log")));
+ assertTrue(Files.exists(this.basePath.resolve("temp/data")));
+ assertTrue(Files.exists(this.basePath.resolve("temp")));
+
+ // Move second file from data directory
+ BlobPath sourcePath2 = BlobPath.of(List.of("temp", "data", "file2.txt"));
+ BlobPath targetPath2 = BlobPath.of(List.of("archive", "file2.txt"));
+
+ this.blobStore.moveBlob(sourcePath2, targetPath2);
+
+ // Now the data directory should be cleaned up, but not temp (logs still there)
+ assertFalse(Files.exists(this.basePath.resolve("temp/data")), "Empty data directory should be cleaned up");
+ assertTrue(Files.exists(this.basePath.resolve("temp")), "Temp directory should still exist (contains logs)");
+ assertTrue(Files.exists(this.basePath.resolve("temp/logs/app.log")));
+ }
+
+ @Test
+ void moveDirectory() throws IOException, BlobStoreException
+ {
+ // Create source directory structure
+ Path sourceDir = this.basePath.resolve("sourceDir");
+ Files.createDirectories(sourceDir);
+ Files.createFile(sourceDir.resolve("file.txt"));
+
+ BlobPath sourcePath = BlobPath.of(List.of("sourceDir"));
+ BlobPath targetPath = BlobPath.of(List.of("targetDir"));
+
+ this.blobStore.moveDirectory(sourcePath, targetPath);
+
+ // Verify source directory is gone
+ assertFalse(Files.exists(sourceDir));
+
+ // Verify target directory exists
+ Path targetDir = this.basePath.resolve("targetDir");
+ assertTrue(Files.exists(targetDir));
+ assertTrue(Files.exists(targetDir.resolve("file.txt")));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'docs', 'archive/docs'",
+ "'src/main', 'src-main'",
+ "'data/cache', 'data/old-cache'"
+ })
+ void moveDirectoryWithNestedStructure(String sourcePath, String targetPath) throws IOException, BlobStoreException
+ {
+ // Create source directory with nested files
+ BlobPath source = BlobPath.from(sourcePath);
+ Path sourceDir = this.basePath.resolve(sourcePath);
+ Files.createDirectories(sourceDir);
+
+ // Create multiple files in the directory
+ Path file1 = sourceDir.resolve("file1.txt");
+ Path file2 = sourceDir.resolve("file2.txt");
+ Path subDir = sourceDir.resolve("subdir");
+ Files.createFile(file1);
+ Files.createFile(file2);
+ Files.createDirectories(subDir);
+ Files.createFile(subDir.resolve("nested.txt"));
+
+ Files.writeString(file1, "Content 1");
+ Files.writeString(file2, "Content 2");
+ Files.writeString(subDir.resolve("nested.txt"), "Nested content");
+
+ BlobPath target = BlobPath.from(targetPath);
+
+ this.blobStore.moveDirectory(source, target);
+
+ // Verify source directory is completely gone
+ assertFalse(Files.exists(sourceDir), "Source directory should be deleted: " + sourcePath);
+
+ // Verify target directory and all its contents exist
+ Path targetDir = this.basePath.resolve(targetPath);
+ assertTrue(Files.exists(targetDir), "Target directory should exist: " + targetPath);
+ assertTrue(Files.exists(targetDir.resolve("file1.txt")));
+ assertTrue(Files.exists(targetDir.resolve("file2.txt")));
+ assertTrue(Files.exists(targetDir.resolve("subdir")));
+ assertTrue(Files.exists(targetDir.resolve("subdir/nested.txt")));
+
+ // Verify content preservation
+ assertEquals("Content 1", Files.readString(targetDir.resolve("file1.txt")));
+ assertEquals("Content 2", Files.readString(targetDir.resolve("file2.txt")));
+ assertEquals("Nested content", Files.readString(targetDir.resolve("subdir/nested.txt")));
+ }
+
+ @Test
+ void moveDirectorySameSourceAndTarget()
+ {
+ BlobPath path = BlobPath.of(List.of("dir"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blobStore.moveDirectory(path, path));
+
+ assertEquals("source and target paths are the same", exception.getMessage());
+ }
+
+ @Test
+ void moveDirectoryTargetExists() throws IOException
+ {
+ // Create source and target directories
+ Path sourceDir = this.basePath.resolve("source");
+ Path targetDir = this.basePath.resolve("target");
+ Files.createDirectories(sourceDir);
+ Files.createDirectories(targetDir);
+ Files.createFile(sourceDir.resolve("file.txt"));
+
+ BlobPath sourcePath = BlobPath.of(List.of("source"));
+ BlobPath targetPath = BlobPath.of(List.of("target"));
+
+ // Should throw exception when target already exists
+ assertThrows(BlobAlreadyExistsException.class,
+ () -> this.blobStore.moveDirectory(sourcePath, targetPath));
+
+ // Verify source directory still exists
+ assertTrue(Files.exists(sourceDir));
+ assertTrue(Files.exists(sourceDir.resolve("file.txt")));
+ }
+
+ @Test
+ void isEmptyDirectoryTrue() throws IOException, BlobStoreException
+ {
+ Path dir = this.basePath.resolve("empty");
+ Files.createDirectories(dir);
+ BlobPath path = BlobPath.of(List.of("empty"));
+
+ assertTrue(this.blobStore.isEmptyDirectory(path));
+ }
+
+ @Test
+ void isEmptyDirectoryFalse() throws IOException, BlobStoreException
+ {
+ Path dir = this.basePath.resolve("nonempty");
+ Files.createDirectories(dir);
+ Files.createFile(dir.resolve("file.txt"));
+ BlobPath path = BlobPath.of(List.of("nonempty"));
+
+ assertFalse(this.blobStore.isEmptyDirectory(path));
+ }
+
+ @Test
+ void isEmptyDirectoryWithNestedEmptyDirectories() throws IOException, BlobStoreException
+ {
+ // Create nested empty directories
+ Path dir = this.basePath.resolve("parent/child/grandchild");
+ Files.createDirectories(dir);
+ BlobPath path = BlobPath.of(List.of("parent"));
+
+ // Directory with only empty subdirectories should be considered empty
+ assertTrue(this.blobStore.isEmptyDirectory(path));
+ }
+
+ @Test
+ void moveBlobSameSourceAndTarget()
+ {
+ BlobPath path = BlobPath.of(List.of("test.txt"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blobStore.moveBlob(path, path));
+
+ assertEquals("source and target paths are the same", exception.getMessage());
+ }
+
+ @Test
+ void moveBlobSourceNotFound()
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("nonexistent", "file.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target", "target.txt"));
+
+ BlobNotFoundException exception = assertThrows(BlobNotFoundException.class,
+ () -> this.blobStore.moveBlob(sourcePath, targetPath));
+ assertEquals(sourcePath, exception.getBlobPath());
+ // Verify that the target directory has been cleaned up if it was created
+ Path targetDir = this.basePath.resolve("target");
+ assertFalse(Files.exists(targetDir), "Target directory should not exist after failed move");
+ }
+
+ @Test
+ void moveBlobTargetAlreadyExists() throws IOException
+ {
+ // Create source and target files
+ Path sourceFile = this.basePath.resolve("docs/source.txt");
+ Path targetFile = this.basePath.resolve("backup/target.txt");
+ Files.createDirectories(sourceFile.getParent());
+ Files.createDirectories(targetFile.getParent());
+ Files.createFile(sourceFile);
+ Files.createFile(targetFile);
+
+ BlobPath sourcePath = BlobPath.of(List.of("docs", "source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("backup", "target.txt"));
+
+ assertThrows(BlobAlreadyExistsException.class,
+ () -> this.blobStore.moveBlob(sourcePath, targetPath));
+
+ // Verify source file still exists after failed move
+ assertTrue(Files.exists(sourceFile));
+ }
+
+ @Test
+ void moveBlobFromDifferentStore() throws Exception
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("target.txt"));
+ String expectedContent = "test content for move operation";
+
+ when(this.mockSourceStore.getBlob(sourcePath)).thenReturn(this.mockSourceBlob);
+ when(this.mockSourceBlob.getStream()).thenReturn(new ByteArrayInputStream(expectedContent.getBytes()));
+
+ Blob result = this.blobStore.moveBlob(this.mockSourceStore, sourcePath, targetPath);
+
+ assertEquals(targetPath, result.getPath());
+ verify(this.mockSourceStore).getBlob(sourcePath);
+ verify(this.mockSourceStore).deleteBlob(sourcePath);
+
+ // Verify that the content has been written to the target location
+ Path targetFile = this.basePath.resolve("target.txt");
+ assertTrue(Files.exists(targetFile));
+ assertEquals(expectedContent, Files.readString(targetFile));
+ }
+
+ @Test
+ void moveBlobFromDifferentFileSystemStore() throws IOException, BlobStoreException
+ {
+ // Create a second FileSystemBlobStore with different base path
+ Path sourceBasePath = this.tmpDir.toPath().resolve("source-store");
+ Files.createDirectories(sourceBasePath);
+ FileSystemBlobStore sourceStore = new FileSystemBlobStore("sourceStore", sourceBasePath);
+
+ // Create source file in the source store
+ BlobPath sourcePath = BlobPath.of(List.of("documents", "file.txt"));
+ Path sourceFile = sourceBasePath.resolve("documents/file.txt");
+ Files.createDirectories(sourceFile.getParent());
+ Files.createFile(sourceFile);
+ String expectedContent = "Content from different FileSystemBlobStore";
+ Files.writeString(sourceFile, expectedContent);
+
+ BlobPath targetPath = BlobPath.of(List.of("moved", "file.txt"));
+
+ Blob result = this.blobStore.moveBlob(sourceStore, sourcePath, targetPath);
+
+ // Verify source file is gone
+ assertFalse(Files.exists(sourceFile));
+ // Verify source directory is cleaned up
+ assertFalse(Files.exists(sourceBasePath.resolve("documents")));
+
+ // Verify target file exists in this store
+ Path targetDir = this.basePath.resolve("moved/file.txt");
+ assertTrue(Files.exists(targetDir));
+ assertEquals(expectedContent, Files.readString(targetDir));
+ assertEquals(targetPath, result.getPath());
+ }
+
+ @Test
+ void moveDirectoryFromDifferentFileSystemStore() throws IOException, BlobStoreException
+ {
+ // Create a second FileSystemBlobStore with different base path
+ Path sourceBasePath = this.tmpDir.toPath().resolve("source-store");
+ Files.createDirectories(sourceBasePath);
+ FileSystemBlobStore sourceStore = new FileSystemBlobStore("sourceStore", sourceBasePath);
+
+ // Create source directory structure in the source store
+ BlobPath sourcePath = BlobPath.of(List.of("project"));
+ Path sourceDir = sourceBasePath.resolve("project");
+ Files.createDirectories(sourceDir);
+
+ // Create multiple files in the source directory
+ Path file1 = sourceDir.resolve("app.java");
+ Path file2 = sourceDir.resolve("config.xml");
+ Path subDir = sourceDir.resolve("lib");
+ Files.createFile(file1);
+ Files.createFile(file2);
+ Files.createDirectories(subDir);
+ Files.createFile(subDir.resolve("library.jar"));
+
+ Files.writeString(file1, "Java application code");
+ Files.writeString(file2, "XML configuration");
+ Files.writeString(subDir.resolve("library.jar"), "JAR content");
+
+ BlobPath targetPath = BlobPath.of(List.of("migrated-project"));
+
+ this.blobStore.moveDirectory(sourceStore, sourcePath, targetPath);
+
+ // Verify source directory is completely gone
+ assertFalse(Files.exists(sourceDir));
+
+ // Verify target directory and all its contents exist in this store
+ Path targetDir = this.basePath.resolve("migrated-project");
+ assertTrue(Files.exists(targetDir));
+ assertTrue(Files.exists(targetDir.resolve("app.java")));
+ assertTrue(Files.exists(targetDir.resolve("config.xml")));
+ assertTrue(Files.exists(targetDir.resolve("lib")));
+ assertTrue(Files.exists(targetDir.resolve("lib/library.jar")));
+
+ // Verify content preservation
+ assertEquals("Java application code", Files.readString(targetDir.resolve("app.java")));
+ assertEquals("XML configuration", Files.readString(targetDir.resolve("config.xml")));
+ assertEquals("JAR content", Files.readString(targetDir.resolve("lib/library.jar")));
+ }
+
+ @Test
+ void moveDirectoryFromDifferentStoreType() throws Exception
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("docs"));
+ BlobPath targetPath = BlobPath.of(List.of("archived-docs"));
+
+ // Create mock blobs for the directory contents
+ List blobs = List.of(mock(), mock(), mock());
+ List blobPaths = List.of(
+ BlobPath.of(List.of("docs", "readme.txt")),
+ BlobPath.of(List.of("docs", "guide.md")),
+ BlobPath.of(List.of("docs", "assets", "logo.png"))
+ );
+ List contents = List.of("README content", "Guide markdown content", "PNG image data");
+
+ for (int i = 0; i < blobs.size(); i++) {
+ when(blobs.get(i).getPath()).thenReturn(blobPaths.get(i));
+ when(blobs.get(i).getStream()).thenReturn(new ByteArrayInputStream(contents.get(i).getBytes()));
+ when(this.mockSourceStore.getBlob(blobPaths.get(i))).thenReturn(blobs.get(i));
+ }
+
+ // Mock the listBlobs method to return our test blobs
+ when(this.mockSourceStore.listBlobs(sourcePath)).thenReturn(blobs.stream());
+
+ this.blobStore.moveDirectory(this.mockSourceStore, sourcePath, targetPath);
+
+ // Verify all blobs were retrieved and deleted from source
+ verify(this.mockSourceStore).listBlobs(sourcePath);
+ for (BlobPath blobPath : blobPaths) {
+ verify(this.mockSourceStore).getBlob(blobPath);
+ verify(this.mockSourceStore).deleteBlob(blobPath);
+ }
+
+ // Verify target files exist in this store with correct content
+ Path targetDir = this.basePath.resolve("archived-docs");
+ List targetFiles = List.of(
+ targetDir.resolve("readme.txt"),
+ targetDir.resolve("guide.md"),
+ targetDir.resolve("assets/logo.png")
+ );
+ for (int i = 0; i < targetFiles.size(); i++) {
+ Path file = targetFiles.get(i);
+ String content = contents.get(i);
+ assertTrue(Files.exists(file), "Target file should exist: " + file);
+ assertEquals(content, Files.readString(file), "Content should match for file: " + file);
+ }
+ }
+
+ @Test
+ void deleteBlob() throws IOException, BlobStoreException
+ {
+ // Create test file in nested directory
+ Path dir = this.basePath.resolve("dir1").resolve("dir2");
+ Files.createDirectories(dir);
+ Path file = dir.resolve("test.txt");
+ Files.createFile(file);
+
+ BlobPath blobPath = BlobPath.of(List.of("dir1", "dir2", "test.txt"));
+
+ this.blobStore.deleteBlob(blobPath);
+
+ // Verify file is deleted
+ assertFalse(Files.exists(file));
+
+ // Parent directories should be cleaned up if empty
+ assertFalse(Files.exists(dir));
+ assertFalse(Files.exists(this.basePath.resolve("dir1")));
+ }
+
+ @Test
+ void deleteBlobNonExistent()
+ {
+ BlobPath blobPath = BlobPath.of(List.of("nonexistent.txt"));
+
+ assertDoesNotThrow(() -> this.blobStore.deleteBlob(blobPath));
+ }
+
+ @Test
+ void deleteBlobs() throws IOException, BlobStoreException
+ {
+ // Create directory with files
+ Path dir = this.basePath.resolve("testdir");
+ Files.createDirectories(dir);
+ Files.createFile(dir.resolve("file1.txt"));
+ Files.createFile(dir.resolve("file2.txt"));
+
+ BlobPath blobPath = BlobPath.of(List.of("testdir"));
+
+ this.blobStore.deleteBlobs(blobPath);
+
+ // Verify directory and all files are deleted
+ assertFalse(Files.exists(dir));
+ }
+
+ @Test
+ void deleteBlobsNonExistentDirectory()
+ {
+ BlobPath blobPath = BlobPath.of(List.of("nonexistent"));
+
+ // Should not throw exception
+ assertDoesNotThrow(() -> this.blobStore.deleteBlobs(blobPath));
+ }
+
+ @Test
+ void createParents() throws IOException
+ {
+ Path directory = this.basePath.resolve("dir1").resolve("dir2");
+ Path targetPath = directory.resolve("file.txt");
+
+ this.blobStore.createParents(targetPath);
+
+ assertTrue(Files.exists(directory));
+ assertTrue(Files.isDirectory(directory));
+ }
+
+ @Test
+ void cleanUpParents() throws IOException
+ {
+ // Create nested empty directories
+ Path nestedDir = this.basePath.resolve("dir1").resolve("dir2").resolve("dir3");
+ Files.createDirectories(nestedDir);
+
+ Path filePath = nestedDir.resolve("file.txt");
+
+ this.blobStore.cleanUpParents(filePath);
+
+ // Empty directories should be cleaned up, but not the base path
+ assertTrue(Files.exists(this.basePath));
+ assertFalse(Files.exists(nestedDir));
+ assertFalse(Files.exists(this.basePath.resolve("dir1").resolve("dir2")));
+ assertFalse(Files.exists(this.basePath.resolve("dir1")));
+ }
+
+ @Test
+ void cleanUpParentsWithNonEmptyDirectory() throws IOException
+ {
+ // Create nested directories with a file in the middle
+ Path dir1 = this.basePath.resolve("dir1");
+ Path dir2 = dir1.resolve("dir2");
+ Path dir3 = dir2.resolve("dir3");
+ Files.createDirectories(dir3);
+
+ // Add a file to dir2 to make it non-empty
+ Files.createFile(dir2.resolve("keepme.txt"));
+
+ Path filePath = dir3.resolve("file.txt");
+
+ this.blobStore.cleanUpParents(filePath);
+
+ // Only empty dir3 should be cleaned up
+ assertTrue(Files.exists(dir2));
+ assertTrue(Files.exists(dir2.resolve("keepme.txt")));
+ assertFalse(Files.exists(dir3));
+ }
+
+ @Test
+ void equalsAndHashCode()
+ {
+ FileSystemBlobStore store1 = new FileSystemBlobStore("test1", this.basePath);
+ FileSystemBlobStore store2 = new FileSystemBlobStore("test2", this.basePath);
+ FileSystemBlobStore store3 = new FileSystemBlobStore("test1", this.tmpDir.toPath().resolve("different"));
+
+ // Equals based on base path only
+ assertEquals(store1, store2);
+ assertNotEquals(store1, store3);
+
+ // Hash code based on base path only
+ assertEquals(store1.hashCode(), store2.hashCode());
+ assertNotEquals(store1.hashCode(), store3.hashCode());
+
+ // Not equals to null or different type
+ assertNotEquals(store1, null);
+ assertNotEquals(store1, "string");
+
+ // Self equality
+ assertEquals(store1, store1);
+ }
+
+ // The following tests use Mockito to mock static Files methods to simulate failures and retries. We only do this
+ // for the move blob operation as the other operations use the same underlying methods and would be redundant to
+ // test.
+
+ @Test
+ void moveBlobRetriesOnNoSuchFileExceptionForParentDirectory() throws BlobStoreException
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("nested", "dir", "target.txt"));
+ Path absoluteSourcePath = this.basePath.resolve("source.txt");
+ Path absoluteTargetPath = this.basePath.resolve("nested/dir/target.txt");
+
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ // Mock that source file exists to avoid that we abort because the source is missing.
+ filesMock.when(() -> Files.exists(absoluteSourcePath)).thenReturn(true);
+
+ // First 2 attempts throw NoSuchFileException, third succeeds
+ filesMock.when(() -> Files.move(any(Path.class), any(Path.class)))
+ .thenThrow(new NoSuchFileException("nested/dir"))
+ .thenThrow(new NoSuchFileException("nested/dir"))
+ .thenReturn(absoluteTargetPath);
+
+ // Should succeed after retries
+ Blob result = this.blobStore.moveBlob(sourcePath, targetPath);
+
+ assertEquals(targetPath, result.getPath());
+
+ // Verify move was attempted 3 times.
+ filesMock.verify(() -> Files.move(absoluteSourcePath, absoluteTargetPath), times(3));
+ // Verify that on each retry, the parent directories were created.
+ filesMock.verify(() -> Files.createDirectories(absoluteTargetPath.getParent()), times(3));
+ }
+ }
+
+ @Test
+ void moveBlobFailsAfterMaxRetries()
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("source.txt"));
+ BlobPath targetPath = BlobPath.of(List.of("nested", "target.txt"));
+
+ try (MockedStatic filesMock = mockStatic(Files.class);
+ MockedStatic pathUtilsMock = mockStatic(PathUtils.class))
+ {
+ // Mock that all directories exist and are empty to trigger cleanup.
+ filesMock.when(() -> Files.exists(any())).thenReturn(true);
+ filesMock.when(() -> Files.isDirectory(any())).thenReturn(true);
+ pathUtilsMock.when(() -> PathUtils.isEmptyDirectory(any())).thenReturn(true);
+
+ // Always throw NoSuchFileException to exceed retry limit
+ filesMock.when(() -> Files.move(any(Path.class), any(Path.class)))
+ .thenThrow(new NoSuchFileException("nested"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blobStore.moveBlob(sourcePath, targetPath));
+
+ assertEquals("move blob failed after 5 attempts", exception.getMessage());
+ assertInstanceOf(NoSuchFileException.class, exception.getCause());
+ // Verify cleanup attempted on target directory.
+ filesMock.verify(() -> Files.deleteIfExists(this.basePath.resolve("nested")));
+ }
+ }
+
+ @Test
+ void moveDirectoryWithIOExceptionDuringMoveTriggersCleanup()
+ {
+ BlobPath sourcePath = BlobPath.of(List.of("sourceDir"));
+ BlobPath targetPath = BlobPath.of(List.of("nested", "targetDir"));
+ Path absoluteTargetPath = this.basePath.resolve("nested/targetDir");
+
+ try (MockedStatic filesMock = mockStatic(Files.class);
+ MockedStatic pathUtilsMock = mockStatic(PathUtils.class))
+ {
+ // Mock that all directories exist and are empty to trigger cleanup.
+ filesMock.when(() -> Files.exists(any())).thenReturn(true);
+ filesMock.when(() -> Files.isDirectory(any())).thenReturn(true);
+ pathUtilsMock.when(() -> PathUtils.isEmptyDirectory(any())).thenReturn(true);
+
+ // Mock Files.move throws IOException to simulate I/O error during directory move
+ filesMock.when(() -> Files.move(any(Path.class), any(Path.class)))
+ .thenThrow(new IOException("I/O error during directory move"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blobStore.moveDirectory(sourcePath, targetPath));
+
+ assertEquals("move blob failed", exception.getMessage());
+ assertInstanceOf(IOException.class, exception.getCause());
+
+ // We shouldn't have deleted the target directory as we never do that.
+ filesMock.verify(() -> Files.deleteIfExists(absoluteTargetPath), never());
+ // But we should have tried to clean up the parent directories if they were empty.
+ filesMock.verify(() -> Files.deleteIfExists(absoluteTargetPath.getParent()));
+ // Cleaning should stop at the base path, so it should not be deleted.
+ filesMock.verify(() -> Files.deleteIfExists(this.basePath), never());
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobTest.java
new file mode 100644
index 0000000000..48f1798be6
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-filesystem/src/test/java/org/xwiki/store/blob/internal/FileSystemBlobTest.java
@@ -0,0 +1,274 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.WriteConditionFailedException;
+import org.xwiki.test.junit5.XWikiTempDir;
+import org.xwiki.test.junit5.XWikiTempDirExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Unit tests for {@link FileSystemBlob}.
+ *
+ * @version $Id$
+ */
+@ExtendWith({ MockitoExtension.class, XWikiTempDirExtension.class })
+class FileSystemBlobTest
+{
+ @XWikiTempDir
+ private File tempDir;
+
+ @Mock
+ private BlobPath blobPath;
+
+ private Path absolutePath;
+
+ @Mock
+ private FileSystemBlobStore store;
+
+ private FileSystemBlob blob;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.absolutePath = this.tempDir.toPath().resolve("testblob.dat");
+ this.blob = new FileSystemBlob(this.blobPath, this.absolutePath, this.store);
+ }
+
+ @Test
+ void constructor()
+ {
+ // Verify that the blob is properly initialized
+ assertEquals(this.blobPath, this.blob.getPath());
+ assertEquals(this.store, this.blob.getStore());
+ }
+
+ @Test
+ void existsWhenFileExists() throws Exception
+ {
+ Files.createFile(this.absolutePath);
+ assertTrue(this.blob.exists());
+ }
+
+ @Test
+ void existsWhenFileDoesNotExist()
+ {
+ assertFalse(this.blob.exists());
+ }
+
+ @Test
+ void getSizeWhenFileExists() throws Exception
+ {
+ Files.write(this.absolutePath, new byte[1024]);
+ assertEquals(1024L, this.blob.getSize());
+ }
+
+ @Test
+ void getSizeWhenFileDoesNotExist() throws Exception
+ {
+ assertEquals(-1L, this.blob.getSize());
+ }
+
+ @Test
+ void getSizeThrowsExceptionOnIOError()
+ {
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ filesMock.when(() -> Files.exists(this.absolutePath, LinkOption.NOFOLLOW_LINKS))
+ .thenReturn(true);
+ filesMock.when(() -> Files.size(this.absolutePath))
+ .thenThrow(new IOException("IO Error"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class, () -> this.blob.getSize());
+ assertEquals("Error getting file size.", exception.getMessage());
+ assertInstanceOf(IOException.class, exception.getCause());
+ }
+ }
+
+ @Test
+ void getOutputStreamWithoutConditions() throws Exception
+ {
+ try (OutputStream result = this.blob.getOutputStream()) {
+ assertNotNull(result);
+ result.write("test data".getBytes());
+ }
+
+ assertTrue(Files.exists(this.absolutePath));
+ assertEquals("test data", Files.readString(this.absolutePath));
+ verify(this.store).createParents(this.absolutePath);
+ }
+
+ @Test
+ void getOutputStreamWithCreateNewCondition() throws Exception
+ {
+ try (OutputStream result = this.blob.getOutputStream(BlobDoesNotExistCondition.INSTANCE)) {
+ assertNotNull(result);
+ result.write("test data".getBytes());
+ }
+
+ assertTrue(Files.exists(this.absolutePath));
+ assertEquals("test data", Files.readString(this.absolutePath));
+ verify(this.store).createParents(this.absolutePath);
+ }
+
+ @Test
+ void getOutputStreamThrowsWriteConditionFailedOnFileExists() throws Exception
+ {
+ // Create the file first so it exists
+ Files.createFile(this.absolutePath);
+
+ WriteConditionFailedException exception = assertThrows(WriteConditionFailedException.class,
+ () -> this.blob.getOutputStream(BlobDoesNotExistCondition.INSTANCE));
+
+ assertEquals(this.blobPath, exception.getBlobPath());
+ assertInstanceOf(FileAlreadyExistsException.class, exception.getCause());
+ }
+
+ @Test
+ void getOutputStreamRetriesOnNoSuchFileException() throws Exception
+ {
+ OutputStream mockOutputStream = mock();
+ NoSuchFileException noSuchFileException = new NoSuchFileException("No parent");
+
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ // First attempt fails with NoSuchFileException, second succeeds
+ filesMock.when(() -> Files.newOutputStream(this.absolutePath))
+ .thenThrow(noSuchFileException)
+ .thenReturn(mockOutputStream);
+
+ OutputStream result = this.blob.getOutputStream();
+
+ assertSame(mockOutputStream, result);
+ verify(this.store, times(2)).createParents(this.absolutePath);
+ }
+ }
+
+ @Test
+ void getOutputStreamThrowsExceptionAfterMaxRetries() throws Exception
+ {
+ NoSuchFileException noSuchFileException = new NoSuchFileException("No parent");
+
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ // All attempts fail with NoSuchFileException
+ filesMock.when(() -> Files.newOutputStream(this.absolutePath))
+ .thenThrow(noSuchFileException);
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blob.getOutputStream());
+
+ assertTrue(exception.getMessage().contains("Error creating parent directories"));
+ assertTrue(exception.getMessage().contains("attempts"));
+ assertSame(noSuchFileException, exception.getCause());
+ verify(this.store, times(FileSystemBlobStore.NUM_ATTEMPTS)).createParents(this.absolutePath);
+ }
+ }
+
+ @Test
+ void getOutputStreamCleansUpOnIOException() throws Exception
+ {
+ IOException ioException = new IOException("Generic IO Error");
+
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ filesMock.when(() -> Files.newOutputStream(this.absolutePath))
+ .thenThrow(ioException);
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blob.getOutputStream());
+
+ assertEquals("Error getting output stream.", exception.getMessage());
+ assertSame(ioException, exception.getCause());
+ verify(this.store).createParents(this.absolutePath);
+ verify(this.store).cleanUpParents(this.absolutePath);
+ }
+ }
+
+ @Test
+ void getStreamSuccess() throws Exception
+ {
+ String expectedContent = "test content";
+ Files.write(this.absolutePath, expectedContent.getBytes());
+
+ String content;
+ try (InputStream result = this.blob.getStream()) {
+ assertNotNull(result);
+ content = IOUtils.toString(result, StandardCharsets.UTF_8);
+ }
+ assertEquals(expectedContent, content);
+ }
+
+ @Test
+ void getStreamThrowsBlobNotFoundOnNoSuchFileException()
+ {
+ BlobNotFoundException exception = assertThrows(BlobNotFoundException.class,
+ () -> this.blob.getStream());
+
+ assertEquals(this.blobPath, exception.getBlobPath());
+ assertInstanceOf(NoSuchFileException.class, exception.getCause());
+ }
+
+ @Test
+ void getStreamThrowsBlobStoreExceptionOnIOException()
+ {
+ IOException ioException = new IOException("Generic IO Error");
+
+ try (MockedStatic filesMock = mockStatic(Files.class)) {
+ filesMock.when(() -> Files.newInputStream(this.absolutePath, LinkOption.NOFOLLOW_LINKS))
+ .thenThrow(ioException);
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.blob.getStream());
+
+ assertEquals("Error getting input stream.", exception.getMessage());
+ assertSame(ioException, exception.getCause());
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/pom.xml b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/pom.xml
new file mode 100644
index 0000000000..aa817c8a79
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/pom.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+ 4.0.0
+
+ org.xwiki.commons
+ xwiki-commons-store-blob
+ 17.10.0-SNAPSHOT
+
+ xwiki-commons-store-blob-s3
+ XWiki Commons - Store - Blob - S3
+ jar
+ Blog storage implementation based on S3-compatible object storage.
+
+ 0.93
+
+
+
+ org.xwiki.commons
+ xwiki-commons-store-blob-api
+ ${project.version}
+
+
+ software.amazon.awssdk
+ s3
+
+
+ software.amazon.awssdk
+ apache-client
+
+
+
+ org.xwiki.commons
+ xwiki-commons-tool-test-component
+ ${project.version}
+ test
+
+
+
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3Blob.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3Blob.java
new file mode 100644
index 0000000000..195d7c0ede
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3Blob.java
@@ -0,0 +1,129 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.WriteCondition;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * A {@link Blob} implementation that represents a blob stored in S3.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public class S3Blob extends AbstractBlob
+{
+ private final String bucketName;
+
+ private final String s3Key;
+
+ private final S3Client s3Client;
+
+ /**
+ * Constructor.
+ *
+ * @param path the blob path
+ * @param bucketName the S3 bucket name
+ * @param s3Key the S3 key for this blob
+ * @param store the parent store
+ * @param s3Client the S3 client
+ */
+ public S3Blob(BlobPath path, String bucketName, String s3Key, S3BlobStore store, S3Client s3Client)
+ {
+ super(store, path);
+ this.bucketName = bucketName;
+ this.s3Key = s3Key;
+ this.s3Client = s3Client;
+ }
+
+ @Override
+ public boolean exists() throws BlobStoreException
+ {
+ try {
+ getHeadObject();
+ return true;
+ } catch (NoSuchKeyException e) {
+ return false;
+ } catch (S3Exception e) {
+ throw new BlobStoreException("Error checking if the blob [%s] exists.".formatted(getPath()), e);
+ }
+ }
+
+ private HeadObjectResponse getHeadObject()
+ {
+ HeadObjectRequest headRequest = HeadObjectRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key)
+ .build();
+
+ return this.s3Client.headObject(headRequest);
+ }
+
+ @Override
+ public long getSize() throws BlobStoreException
+ {
+ try {
+ return getHeadObject().contentLength();
+ } catch (NoSuchKeyException e) {
+ return -1;
+ } catch (S3Exception e) {
+ throw new BlobStoreException("Failed to get size for blob: %s".formatted(getPath()), e);
+ }
+ }
+
+ @Override
+ public OutputStream getOutputStream(WriteCondition... conditions) throws BlobStoreException
+ {
+ long partSizeBytes = ((S3BlobStore) this.getStore()).getMultipartPartUploadSizeBytes();
+ return new S3BlobOutputStream(this.bucketName, this.s3Key, this.s3Client,
+ Arrays.asList(conditions), getPath(), partSizeBytes);
+ }
+
+ @Override
+ public InputStream getStream() throws BlobStoreException
+ {
+ try {
+ GetObjectRequest getRequest = GetObjectRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key)
+ .build();
+
+ return this.s3Client.getObject(getRequest);
+ } catch (NoSuchKeyException e) {
+ throw new BlobNotFoundException(getPath(), e);
+ } catch (S3Exception e) {
+ throw new BlobStoreException("Failed to get stream for blob: %s".formatted(getPath()), e);
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobIterator.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobIterator.java
new file mode 100644
index 0000000000..4128a28246
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobIterator.java
@@ -0,0 +1,156 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+/**
+ * Iterator that fetches S3 objects page by page and converts them to Blob objects.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+final class S3BlobIterator implements Iterator
+{
+ private final String prefix;
+
+ private final String bucketName;
+
+ private final int pageSize;
+
+ private final S3Client s3Client;
+
+ private final S3BlobStore store;
+
+ private String continuationToken;
+
+ private Iterator currentPageIterator;
+
+ private boolean hasMorePages = true;
+
+ private Blob nextBlob;
+
+ private boolean nextBlobComputed;
+
+ /**
+ * Create a new iterator that pages through S3 objects.
+ *
+ * @param prefix the S3 key prefix to list
+ * @param bucketName the S3 bucket name
+ * @param s3Client the S3 client
+ * @param pageSize the page size to use for fetching files
+ * @param store the parent BlobStore
+ */
+ S3BlobIterator(String prefix, String bucketName, int pageSize, S3Client s3Client, S3BlobStore store)
+ {
+ this.prefix = prefix;
+ this.bucketName = bucketName;
+ this.pageSize = pageSize;
+ this.s3Client = s3Client;
+ this.store = store;
+ }
+
+ @Override
+ public boolean hasNext()
+ {
+ if (!this.nextBlobComputed) {
+ this.nextBlob = computeNext();
+ this.nextBlobComputed = true;
+ }
+ return this.nextBlob != null;
+ }
+
+ @Override
+ public Blob next()
+ {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+
+ Blob result = this.nextBlob;
+ this.nextBlob = null;
+ this.nextBlobComputed = false;
+ return result;
+ }
+
+ private Blob computeNext()
+ {
+ while (true) {
+ if (this.currentPageIterator != null && this.currentPageIterator.hasNext()) {
+ S3Object s3Object = this.currentPageIterator.next();
+ Blob blob = convertToBlob(s3Object);
+ if (blob != null) {
+ return blob;
+ }
+ continue;
+ }
+
+ if (!this.hasMorePages) {
+ return null;
+ }
+
+ fetchNextPage();
+ }
+ }
+
+ private void fetchNextPage() throws S3Exception
+ {
+ ListObjectsV2Request.Builder requestBuilder = ListObjectsV2Request.builder()
+ .bucket(this.bucketName)
+ .prefix(this.prefix)
+ .maxKeys(this.pageSize);
+
+ if (this.continuationToken != null) {
+ requestBuilder.continuationToken(this.continuationToken);
+ }
+
+ ListObjectsV2Response response = this.s3Client.listObjectsV2(requestBuilder.build());
+
+ this.currentPageIterator = response.contents().iterator();
+ this.continuationToken = response.nextContinuationToken();
+ this.hasMorePages = response.isTruncated();
+ }
+
+ private Blob convertToBlob(S3Object s3Object)
+ {
+ String key = s3Object.key();
+
+ if (key.endsWith("/")) {
+ return null;
+ }
+
+ BlobPath blobPath = this.store.getKeyMapper().s3KeyToBlobPath(key);
+ if (blobPath != null) {
+ return new S3Blob(blobPath, this.bucketName, key, this.store, this.s3Client);
+ }
+
+ return null;
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobOutputStream.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobOutputStream.java
new file mode 100644
index 0000000000..21b4434f24
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobOutputStream.java
@@ -0,0 +1,325 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.WriteCondition;
+import org.xwiki.store.blob.WriteConditionFailedException;
+
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.UploadPartRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartResponse;
+
+/**
+ * An OutputStream implementation that uses streaming multipart uploads for large files
+ * and simple uploads for small files to efficiently handle data without loading everything into memory.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public class S3BlobOutputStream extends OutputStream
+{
+ private static final String WILDCARD = "*";
+
+ private static final String GENERIC_FAILED_UPLOAD_MESSAGE = "Failed to upload to S3";
+
+ /**
+ * Extended ByteArrayOutputStream to expose an InputStream view of the current buffer.
+ * This avoids unnecessary copying of the internal buffer when uploading to S3.
+ */
+ private static class ByteArrayOutputStreamWithInputStream extends ByteArrayOutputStream
+ {
+ ByteArrayOutputStreamWithInputStream()
+ {
+ super();
+ }
+
+ public InputStream toInputStream()
+ {
+ return new ByteArrayInputStream(this.buf, 0, this.count);
+ }
+ }
+
+ private final String bucketName;
+
+ private final String s3Key;
+
+ private final S3Client s3Client;
+
+ private final ByteArrayOutputStreamWithInputStream buffer;
+
+ private final int partSize;
+
+ private boolean closed;
+
+ private boolean failed;
+
+ private final List writeConditions;
+
+ private final BlobPath blobPath;
+
+ // Multipart upload helper
+ private S3MultipartUploadHelper uploadHelper;
+
+ /**
+ * Constructor with write condition.
+ *
+ * @param bucketName the S3 bucket name
+ * @param s3Key the S3 key
+ * @param s3Client the S3 client
+ * @param conditions the write conditions to check
+ * @param blobPath the blob path for error reporting
+ * @param partSizeBytes the configured multipart upload part size in bytes
+ */
+ public S3BlobOutputStream(String bucketName, String s3Key, S3Client s3Client, List conditions,
+ BlobPath blobPath, long partSizeBytes)
+ {
+ this.bucketName = bucketName;
+ this.s3Key = s3Key;
+ this.s3Client = s3Client;
+ this.writeConditions = conditions;
+ this.blobPath = blobPath;
+ this.buffer = new ByteArrayOutputStreamWithInputStream();
+ this.failed = false;
+ // Cap part size to Integer.MAX_VALUE since ByteArrayOutputStream uses int for size (and about 2GB is in
+ // fact already too much as upload buffer).
+ this.partSize = (int) Math.min(partSizeBytes, Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void write(int b) throws IOException
+ {
+ checkStreamState();
+
+ // Success flag to track if write completed without exceptions.
+ boolean success = false;
+ try {
+ this.buffer.write(b);
+
+ // Check if we need to upload a part.
+ if (this.buffer.size() >= this.partSize) {
+ uploadPartFromBuffer();
+ }
+
+ success = true;
+ } finally {
+ if (!success) {
+ // An error occurred during write, mark stream as failed.
+ markAsFailedAndCleanup();
+ }
+ }
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException
+ {
+ write(b, 0, b.length);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException
+ {
+ checkStreamState();
+
+ // Success flag to track if write completed without exceptions.
+ boolean success = false;
+ try {
+ int remaining = len;
+ int offset = off;
+
+ while (remaining > 0) {
+ int spaceInBuffer = this.partSize - this.buffer.size();
+ int toWrite = Math.min(remaining, spaceInBuffer);
+
+ this.buffer.write(b, offset, toWrite);
+ offset += toWrite;
+ remaining -= toWrite;
+
+ // Check if we need to upload a part
+ if (this.buffer.size() >= this.partSize) {
+ uploadPartFromBuffer();
+ }
+ }
+
+ success = true;
+ } finally {
+ if (!success) {
+ // An error occurred during write, mark stream as failed.
+ markAsFailedAndCleanup();
+ }
+ }
+ }
+
+ @Override
+ public void flush() throws IOException
+ {
+ checkStreamState();
+ // For S3, we do not need to do anything special on flush
+ // Data will be uploaded in parts or on close
+ }
+
+ @Override
+ public void close() throws IOException
+ {
+ if (this.closed) {
+ return;
+ }
+
+ this.closed = true;
+
+ if (this.failed) {
+ // If already in failed state, just return.
+ return;
+ }
+
+ // Success flag to track if close completed without exceptions.
+ boolean success = false;
+ try {
+ if (this.uploadHelper != null) {
+ // Complete multipart upload.
+ // Upload the final part (if any data is left in the buffer).
+ uploadPartFromBuffer();
+ this.uploadHelper.complete();
+ } else {
+ // Small file - use simple upload
+ uploadSimple();
+ }
+ success = true;
+ } finally {
+ if (!success) {
+ // An error occurred during close, mark stream as failed.
+ markAsFailedAndCleanup();
+ }
+ }
+ }
+
+ private void uploadPartFromBuffer() throws IOException
+ {
+ if (this.buffer.size() == 0) {
+ return;
+ }
+
+ // Initialize multipart upload if not already done.
+ ensureMultipartUploadInitialized();
+
+ // Get the next part number and ensure we have not exceeded limits.
+ int partNumber = this.uploadHelper.getNextPartNumber();
+
+ try (InputStream inputStream = this.buffer.toInputStream()) {
+ // Upload as part of multipart upload.
+ UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key)
+ .uploadId(this.uploadHelper.getUploadId())
+ .partNumber(partNumber)
+ .build();
+
+ UploadPartResponse response = this.s3Client.uploadPart(uploadPartRequest,
+ RequestBody.fromInputStream(inputStream, this.buffer.size()));
+
+ this.uploadHelper.addCompletedPart(response.eTag());
+
+ // Clear the buffer for the next part
+ this.buffer.reset();
+ } catch (Exception e) {
+ throw new IOException("Failed to upload part to S3", e);
+ }
+ }
+
+ private void ensureMultipartUploadInitialized() throws IOException
+ {
+ if (this.uploadHelper != null) {
+ return;
+ }
+
+ this.uploadHelper = new S3MultipartUploadHelper(
+ this.bucketName,
+ this.s3Key,
+ this.s3Client,
+ this.blobPath,
+ this.writeConditions
+ );
+ }
+
+ private void uploadSimple() throws IOException
+ {
+ try (InputStream inputStream = this.buffer.toInputStream()) {
+ PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key);
+
+ // Add conditional headers if needed.
+ if (hasIfNotExistsCondition()) {
+ requestBuilder.ifNoneMatch(WILDCARD);
+ }
+
+ this.s3Client.putObject(requestBuilder.build(),
+ RequestBody.fromInputStream(inputStream, this.buffer.size()));
+ } catch (S3Exception e) {
+ handleS3Exception(e);
+ } catch (Exception e) {
+ throw new IOException(GENERIC_FAILED_UPLOAD_MESSAGE, e);
+ }
+ }
+
+ private void handleS3Exception(S3Exception e) throws IOException
+ {
+ // Check if this is a precondition failed error (412) for conditional requests.
+ if (e.statusCode() == 412 && hasIfNotExistsCondition()) {
+ throw new IOException("Write condition failed - blob already exists",
+ new WriteConditionFailedException(this.blobPath, this.writeConditions, e));
+ }
+ throw new IOException(GENERIC_FAILED_UPLOAD_MESSAGE, e);
+ }
+
+ private boolean hasIfNotExistsCondition()
+ {
+ return this.writeConditions != null && this.writeConditions.contains(BlobDoesNotExistCondition.INSTANCE);
+ }
+
+ private void checkStreamState() throws IOException
+ {
+ if (this.closed) {
+ throw new IOException("Stream closed");
+ }
+ if (this.failed) {
+ throw new IOException("Stream is in failed state due to previous error");
+ }
+ }
+
+ private void markAsFailedAndCleanup()
+ {
+ this.failed = true;
+ if (this.uploadHelper != null) {
+ this.uploadHelper.abort();
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStore.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStore.java
new file mode 100644
index 0000000000..ed47ab53e5
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStore.java
@@ -0,0 +1,210 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import jakarta.inject.Inject;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.component.annotation.InstantiationStrategy;
+import org.xwiki.component.descriptor.ComponentInstantiationStrategy;
+import org.xwiki.store.blob.AbstractBlobStore;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+
+/**
+ * S3-based blob store implementation.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = S3BlobStore.class)
+@InstantiationStrategy(ComponentInstantiationStrategy.PER_LOOKUP)
+public class S3BlobStore extends AbstractBlobStore
+{
+ private String bucketName;
+
+ @Inject
+ private S3ClientManager clientManager;
+
+ @Inject
+ private S3BlobStoreConfiguration configuration;
+
+ private S3KeyMapper keyMapper;
+
+ @Inject
+ private S3CopyOperations copyOperations;
+
+ @Inject
+ private S3DeleteOperations deleteOperations;
+
+ /**
+ * Default constructor for component manager.
+ */
+ public S3BlobStore()
+ {
+ super(null);
+ }
+
+ /**
+ * Initialize this blob store, must be called before performing any other operations.
+ *
+ * @param name the name of this blob store
+ * @param bucketName the S3 bucket name
+ * @param keyPrefix the key prefix for all objects in this store
+ */
+ public void initialize(String name, String bucketName, String keyPrefix)
+ {
+ this.name = name;
+ this.bucketName = bucketName;
+ this.keyMapper = new S3KeyMapper(keyPrefix);
+ }
+
+ @Override
+ public Blob getBlob(BlobPath path) throws BlobStoreException
+ {
+ String s3Key = this.keyMapper.buildS3Key(path);
+ return new S3Blob(path, this.bucketName, s3Key, this, this.clientManager.getS3Client());
+ }
+
+ @Override
+ public Stream listBlobs(BlobPath path)
+ {
+ String prefix = this.keyMapper.getS3KeyPrefix(path);
+
+ return StreamSupport.stream(Spliterators.spliteratorUnknownSize(
+ new S3BlobIterator(prefix, this.bucketName, 1000, this.clientManager.getS3Client(), this),
+ Spliterator.ORDERED), false);
+ }
+
+ @Override
+ public Blob copyBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ return this.copyOperations.copyBlob(this, sourcePath, this, targetPath);
+ }
+
+ @Override
+ public Blob copyBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ return this.copyOperations.copyBlob(sourceStore, sourcePath, this, targetPath);
+ }
+
+ @Override
+ public Blob moveBlob(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ Blob movedBlob = copyBlob(sourcePath, targetPath);
+ deleteBlob(sourcePath);
+ return movedBlob;
+ }
+
+ @Override
+ public Blob moveBlob(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException
+ {
+ Blob movedBlob = copyBlob(sourceStore, sourcePath, targetPath);
+ sourceStore.deleteBlob(sourcePath);
+ return movedBlob;
+ }
+
+ @Override
+ public boolean isEmptyDirectory(BlobPath path) throws BlobStoreException
+ {
+ try {
+ // Fetch with a page size of 1 as we only ever request the first element
+ return !new S3BlobIterator(this.keyMapper.getS3KeyPrefix(path), this.bucketName, 1,
+ this.clientManager.getS3Client(), this).hasNext();
+ } catch (Exception e) {
+ // The code doesn't throw any checked exceptions as the iterator cannot throw them, but we catch any
+ // runtime exceptions to make them nicer to handle for the caller
+ throw new BlobStoreException("Failed to check if directory is empty: " + path, e);
+ }
+ }
+
+ @Override
+ public void deleteBlob(BlobPath path) throws BlobStoreException
+ {
+ this.deleteOperations.deleteBlob(this, path);
+ }
+
+ @Override
+ public void deleteBlobs(BlobPath path) throws BlobStoreException
+ {
+ try (Stream blobs = listBlobs(path)) {
+ this.deleteOperations.deleteBlobs(this, blobs);
+ }
+ }
+
+ /**
+ * Get the bucket name.
+ *
+ * @return the bucket name
+ */
+ String getBucketName()
+ {
+ return this.bucketName;
+ }
+
+ /**
+ * Get the key mapper.
+ *
+ * @return the key mapper
+ */
+ S3KeyMapper getKeyMapper()
+ {
+ return this.keyMapper;
+ }
+
+ /**
+ * @return the configured multipart part size in bytes
+ */
+ public long getMultipartPartUploadSizeBytes()
+ {
+ return this.configuration.getS3MultipartPartUploadSizeBytes();
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof S3BlobStore that)) {
+ return false;
+ }
+
+ return new EqualsBuilder().append(this.bucketName, that.bucketName)
+ .append(this.keyMapper, that.keyMapper)
+ .isEquals();
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return new HashCodeBuilder(17, 37).append(this.bucketName).append(this.keyMapper).toHashCode();
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreConfiguration.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreConfiguration.java
new file mode 100644
index 0000000000..41ebe71718
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreConfiguration.java
@@ -0,0 +1,176 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.inject.Singleton;
+
+import org.xwiki.component.annotation.Component;
+import org.xwiki.configuration.ConfigurationSource;
+
+/**
+ * Configuration for the S3-based Blob Store.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = S3BlobStoreConfiguration.class)
+@Singleton
+public class S3BlobStoreConfiguration
+{
+ @Inject
+ @Named("xwikiproperties")
+ private ConfigurationSource configurationSource;
+
+ /**
+ * @return the name of the S3 bucket where blobs will be stored
+ */
+ public String getS3BucketName()
+ {
+ return this.configurationSource.getProperty("store.s3.bucketName");
+ }
+
+ /**
+ * @return the AWS region where the S3 bucket is located (defaults to "us-east-1")
+ */
+ public String getS3Region()
+ {
+ return this.configurationSource.getProperty("store.s3.region", "us-east-1");
+ }
+
+ /**
+ * @return the AWS access key for authenticating with S3
+ */
+ public String getS3AccessKey()
+ {
+ return this.configurationSource.getProperty("store.s3.accessKey");
+ }
+
+ /**
+ * @return the AWS secret key for authenticating with S3
+ */
+ public String getS3SecretKey()
+ {
+ return this.configurationSource.getProperty("store.s3.secretKey");
+ }
+
+ /**
+ * @return the S3 endpoint URL when using a custom S3-compatible service
+ */
+ public String getS3Endpoint()
+ {
+ return this.configurationSource.getProperty("store.s3.endpoint");
+ }
+
+ /**
+ * @return whether to use path-style access for S3 URLs (defaults to false)
+ */
+ public boolean isS3PathStyleAccess()
+ {
+ return this.configurationSource.getProperty("store.s3.pathStyleAccess", false);
+ }
+
+ /**
+ * @return the maximum number of concurrent connections to S3 (defaults to 50)
+ */
+ public int getS3MaxConnections()
+ {
+ return this.configurationSource.getProperty("store.s3.maxConnections", 50);
+ }
+
+ /**
+ * @return the connection timeout in milliseconds for S3 requests (defaults to 10000)
+ */
+ public int getS3ConnectionTimeout()
+ {
+ return this.configurationSource.getProperty("store.s3.connectionTimeout", 10000);
+ }
+
+ /**
+ * @return the socket timeout in milliseconds for S3 requests (defaults to 50000)
+ */
+ public int getS3SocketTimeout()
+ {
+ return this.configurationSource.getProperty("store.s3.socketTimeout", 50000);
+ }
+
+ /**
+ * @return the request timeout in milliseconds for S3 requests (defaults to 300000)
+ */
+ public int getS3RequestTimeout()
+ {
+ return this.configurationSource.getProperty("store.s3.requestTimeout", 300000);
+ }
+
+ /**
+ * @return the maximum number of retries for failed S3 requests (defaults to 3)
+ */
+ public int getS3MaxRetries()
+ {
+ return this.configurationSource.getProperty("store.s3.maxRetries", 3);
+ }
+
+ /**
+ * @return the key prefix to prepend to all S3 object keys (defaults to "")
+ */
+ public String getS3KeyPrefix()
+ {
+ return this.configurationSource.getProperty("store.s3.keyPrefix", "");
+ }
+
+ /**
+ * Returns the configured multipart upload part size in bytes. Defaults to 5 MB if not configured. Uploads above
+ * this size will use multipart upload with parts of this size.
+ *
+ * @return the part size in bytes
+ */
+ public long getS3MultipartPartUploadSizeBytes()
+ {
+ int sizeMB = this.configurationSource.getProperty("store.s3.multipartPartUploadSizeMB", 5);
+
+ return convertToBytesAndLimitPartSize(sizeMB);
+ }
+
+ /**
+ * Returns the configured multipart copy size threshold in bytes. Defaults to 512 MB if not configured.
+ * Objects larger than this threshold will use multipart copy operations with parts of this size.
+ * If the size is smaller than {@link #getS3MultipartPartUploadSizeBytes()}, it will be increased to match it.
+ *
+ * @return the copy size threshold in bytes
+ */
+ public long getS3MultipartCopySizeBytes()
+ {
+ int sizeMB = this.configurationSource.getProperty("store.s3.multipartCopySizeMB", 512);
+
+ long copyBytes = convertToBytesAndLimitPartSize(sizeMB);
+
+ // Ensure the copy part size is at least the upload part size so that copying an object won't require more
+ // parts than uploading the same object.
+ long uploadPartSize = getS3MultipartPartUploadSizeBytes();
+ return Math.max(copyBytes, uploadPartSize);
+ }
+
+ private static long convertToBytesAndLimitPartSize(int sizeMB)
+ {
+ return Math.max(Math.min(sizeMB * 1024L * 1024L, S3MultipartUploadHelper.MAX_PART_SIZE),
+ S3MultipartUploadHelper.MIN_PART_SIZE);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreManager.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreManager.java
new file mode 100644
index 0000000000..04593a35e7
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3BlobStoreManager.java
@@ -0,0 +1,124 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.inject.Provider;
+import jakarta.inject.Singleton;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.component.phase.Initializable;
+import org.xwiki.component.phase.InitializationException;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.BlobStoreManager;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * Blob store manager for the S3-based blob store.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component
+@Named("s3")
+@Singleton
+public class S3BlobStoreManager implements BlobStoreManager, Initializable
+{
+ @Inject
+ private Logger logger;
+
+ @Inject
+ private S3BlobStoreConfiguration configuration;
+
+ @Inject
+ private S3ClientManager clientManager;
+
+ @Inject
+ private Provider blobStoreProvider;
+
+ private String bucketName;
+
+ @Override
+ public void initialize() throws InitializationException
+ {
+ this.bucketName = this.configuration.getS3BucketName();
+ if (StringUtils.isBlank(this.bucketName)) {
+ throw new InitializationException("S3 bucket name is required but not configured. "
+ + "Please set the 'store.s3.bucketName' property.");
+ }
+
+ // Verify bucket access
+ try {
+ validateBucketAccess();
+ this.logger.info("S3 blob store manager initialized for bucket: {}", this.bucketName);
+ } catch (Exception e) {
+ throw new InitializationException("Failed to validate S3 bucket access", e);
+ }
+ }
+
+ @Override
+ public BlobStore getBlobStore(String name) throws BlobStoreException
+ {
+ String keyPrefix = this.configuration.getS3KeyPrefix();
+ if (StringUtils.isNotBlank(keyPrefix) && StringUtils.isNotBlank(name)) {
+ // Use store name as additional prefix if not default
+ keyPrefix = keyPrefix + "/" + name;
+ } else if (!StringUtils.isBlank(name)) {
+ // Use store name as prefix if no global prefix configured
+ keyPrefix = name;
+ }
+
+ S3BlobStore blobStore = this.blobStoreProvider.get();
+ blobStore.initialize(name, this.bucketName, keyPrefix);
+ return blobStore;
+ }
+
+ private void validateBucketAccess() throws BlobStoreException
+ {
+ try {
+ S3Client s3Client = this.clientManager.getS3Client();
+ HeadBucketRequest headBucketRequest = HeadBucketRequest.builder()
+ .bucket(this.bucketName)
+ .build();
+
+ s3Client.headBucket(headBucketRequest);
+ this.logger.debug("Successfully validated access to S3 bucket: {}", this.bucketName);
+ } catch (NoSuchBucketException e) {
+ throw new BlobStoreException("S3 bucket does not exist: " + this.bucketName, e);
+ } catch (S3Exception e) {
+ if (e.statusCode() == 403) {
+ throw new BlobStoreException("Access denied to S3 bucket: " + this.bucketName
+ + ". Please check credentials and bucket permissions.", e);
+ } else {
+ throw new BlobStoreException("Failed to access S3 bucket: " + this.bucketName, e);
+ }
+ } catch (Exception e) {
+ throw new BlobStoreException("Unexpected error while validating S3 bucket access", e);
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3ClientManager.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3ClientManager.java
new file mode 100644
index 0000000000..d0c7f7a00e
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3ClientManager.java
@@ -0,0 +1,157 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.net.URI;
+import java.time.Duration;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.component.phase.Disposable;
+import org.xwiki.component.phase.Initializable;
+import org.xwiki.component.phase.InitializationException;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.apache.ApacheHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3ClientBuilder;
+
+/**
+ * Component responsible for managing S3 client connections with proper pooling and configuration.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = S3ClientManager.class)
+@Singleton
+public class S3ClientManager implements Initializable, Disposable
+{
+ @Inject
+ private Logger logger;
+
+ @Inject
+ private S3BlobStoreConfiguration configuration;
+
+ private S3Client s3Client;
+
+ private SdkHttpClient httpClient;
+
+ @Override
+ public void initialize() throws InitializationException
+ {
+ try {
+ this.s3Client = createS3Client();
+ this.logger.info("S3 client initialized successfully");
+ } catch (Exception e) {
+ throw new InitializationException("Failed to initialize S3 client", e);
+ }
+ }
+
+ @Override
+ public void dispose()
+ {
+ if (this.s3Client != null) {
+ this.s3Client.close();
+ this.s3Client = null;
+ this.logger.info("S3 client disposed");
+ }
+
+ // Properly close the HTTP client to release connection pools.
+ if (this.httpClient != null) {
+ this.httpClient.close();
+ this.httpClient = null;
+ }
+ }
+
+ /**
+ * Get the configured S3 client.
+ *
+ * @return the S3 client instance
+ */
+ public S3Client getS3Client()
+ {
+ return this.s3Client;
+ }
+
+ private S3Client createS3Client()
+ {
+ S3ClientBuilder builder = S3Client.builder();
+
+ // Configure region
+ String region = this.configuration.getS3Region();
+ if (StringUtils.isNotBlank(region)) {
+ builder.region(Region.of(region));
+ }
+
+ // Configure credentials
+ AwsCredentialsProvider credentialsProvider = createCredentialsProvider();
+ builder.credentialsProvider(credentialsProvider);
+
+ // Configure endpoint (for S3-compatible services like MinIO)
+ String endpoint = this.configuration.getS3Endpoint();
+ if (StringUtils.isNotBlank(endpoint)) {
+ builder.endpointOverride(URI.create(endpoint));
+ }
+
+ // Configure S3-specific settings
+ builder.serviceConfiguration(c -> c.pathStyleAccessEnabled(this.configuration.isS3PathStyleAccess()));
+
+ // Configure HTTP client with connection pooling
+ this.httpClient = ApacheHttpClient.builder()
+ .maxConnections(this.configuration.getS3MaxConnections())
+ .connectionTimeout(Duration.ofMillis(this.configuration.getS3ConnectionTimeout()))
+ .socketTimeout(Duration.ofMillis(this.configuration.getS3SocketTimeout()))
+ .build();
+ builder.httpClient(this.httpClient);
+
+ // Configure timeouts and retry strategy
+ builder.overrideConfiguration(c -> c
+ .apiCallTimeout(Duration.ofMillis(this.configuration.getS3RequestTimeout()))
+ .retryStrategy(retryStrategy -> retryStrategy.maxAttempts(this.configuration.getS3MaxRetries())));
+
+ return builder.build();
+ }
+
+ private AwsCredentialsProvider createCredentialsProvider()
+ {
+ String accessKey = this.configuration.getS3AccessKey();
+ String secretKey = this.configuration.getS3SecretKey();
+
+ if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) {
+ // Use explicit credentials from configuration
+ AwsCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
+ return StaticCredentialsProvider.create(credentials);
+ } else {
+ // Use default credential provider chain (environment variables, instance profile, etc.)
+ this.logger.info("Using default AWS credentials provider chain");
+ return DefaultCredentialsProvider.builder().build();
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3CopyOperations.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3CopyOperations.java
new file mode 100644
index 0000000000..f05eea5ced
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3CopyOperations.java
@@ -0,0 +1,274 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.List;
+import java.util.Map;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobAlreadyExistsException;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.MetadataDirective;
+import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartCopyResponse;
+
+/**
+ * Strategy for copying blobs in S3, supporting both simple and multipart copies.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = S3CopyOperations.class)
+@Singleton
+public class S3CopyOperations
+{
+ @Inject
+ private S3ClientManager clientManager;
+
+ @Inject
+ private S3BlobStoreConfiguration configuration;
+
+ @Inject
+ private Logger logger;
+
+ /**
+ * Copy a blob from any blob store to another blob store. If both stores are S3 blob stores, use S3's server-side
+ * copy capabilities.
+ *
+ * @param sourceStore the source blob store
+ * @param sourcePath the source blob path
+ * @param targetStore the target blob store
+ * @param targetPath the target blob path
+ * @return the copied blob
+ * @throws BlobStoreException if the copy operation fails
+ */
+ public Blob copyBlob(BlobStore sourceStore, BlobPath sourcePath, BlobStore targetStore, BlobPath targetPath)
+ throws BlobStoreException
+ {
+ if (sourceStore.equals(targetStore) && sourcePath.equals(targetPath)) {
+ throw new BlobStoreException("Source and target blob are the same: " + sourcePath);
+ }
+
+ if (sourceStore instanceof S3BlobStore sourceS3Store && targetStore instanceof S3BlobStore targetS3Store) {
+ return copyBlobS3Store(sourceS3Store, sourcePath, targetS3Store, targetPath);
+ }
+
+ return copyBlobWithStream(sourceStore, sourcePath, targetStore, targetPath);
+ }
+
+ /**
+ * Copy a blob from any blob store to another blob store using streams.
+ *
+ * @param sourceStore the source blob store
+ * @param sourcePath the source blob path
+ * @param targetStore the target blob store
+ * @param targetPath the target blob path
+ * @return the copied blob
+ * @throws BlobStoreException if the copy operation fails
+ */
+ private Blob copyBlobWithStream(BlobStore sourceStore, BlobPath sourcePath, BlobStore targetStore,
+ BlobPath targetPath)
+ throws BlobStoreException
+ {
+ try (var inputStream = sourceStore.getBlob(sourcePath).getStream()) {
+ Blob targetBlob = targetStore.getBlob(targetPath);
+
+ // Check if target already exists before writing
+ if (targetBlob.exists()) {
+ throw new BlobAlreadyExistsException(targetPath);
+ }
+
+ targetBlob.writeFromStream(inputStream, BlobDoesNotExistCondition.INSTANCE);
+ return targetBlob;
+ } catch (BlobStoreException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BlobStoreException("Failed to copy blob from external store", e);
+ }
+ }
+
+ /**
+ * Copy a blob within S3 using S3's server-side copy capabilities.
+ *
+ * @param sourceStore the source S3 blob store
+ * @param sourcePath the source blob path
+ * @param targetStore the target S3 blob store
+ * @param targetPath the target blob path
+ * @return the copied blob
+ * @throws BlobStoreException if the copy operation fails
+ */
+ private Blob copyBlobS3Store(S3BlobStore sourceStore, BlobPath sourcePath, S3BlobStore targetStore,
+ BlobPath targetPath)
+ throws BlobStoreException
+ {
+ String sourceKey = sourceStore.getKeyMapper().buildS3Key(sourcePath);
+ String targetKey = targetStore.getKeyMapper().buildS3Key(targetPath);
+
+ // Check if target already exists
+ Blob targetBlob = targetStore.getBlob(targetPath);
+ if (targetBlob.exists()) {
+ throw new BlobAlreadyExistsException(targetPath);
+ }
+
+ // Get source object size
+ Blob sourceBlob = sourceStore.getBlob(sourcePath);
+ long objectSize = sourceBlob.getSize();
+
+ if (objectSize < 0) {
+ throw new BlobNotFoundException(sourcePath);
+ }
+
+ // Choose copy strategy based on object size
+ if (objectSize <= this.configuration.getS3MultipartCopySizeBytes()) {
+ performSimpleCopy(sourceStore.getBucketName(), sourceKey, targetStore.getBucketName(), targetKey);
+ } else {
+ performMultipartCopy(sourceStore.getBucketName(), sourceKey, targetStore.getBucketName(), targetKey,
+ targetPath, objectSize);
+ }
+
+ return targetStore.getBlob(targetPath);
+ }
+
+ /**
+ * Performs a simple single-operation copy for objects smaller than 5GB.
+ *
+ * @param sourceBucket the source S3 bucket
+ * @param sourceKey the source S3 key
+ * @param targetBucket the target S3 bucket
+ * @param targetKey the target S3 key
+ * @throws BlobStoreException if the copy operation fails
+ */
+ private void performSimpleCopy(String sourceBucket, String sourceKey, String targetBucket, String targetKey)
+ throws BlobStoreException
+ {
+ try {
+ CopyObjectRequest copyRequest = CopyObjectRequest.builder()
+ .sourceBucket(sourceBucket)
+ .sourceKey(sourceKey)
+ .destinationBucket(targetBucket)
+ .destinationKey(targetKey)
+ .metadataDirective(MetadataDirective.COPY)
+ .build();
+
+ this.clientManager.getS3Client().copyObject(copyRequest);
+ } catch (Exception e) {
+ throw new BlobStoreException("Failed to perform simple copy", e);
+ }
+ }
+
+ /**
+ * Performs a multipart copy for objects larger than 5GB.
+ *
+ * @param sourceBucket the source S3 bucket
+ * @param sourceKey the source S3 key
+ * @param targetBucket the target S3 bucket
+ * @param targetKey the target S3 key
+ * @param targetPath the target blob path (for error reporting)
+ * @param objectSize the size of the object in bytes
+ * @throws BlobStoreException if the multipart copy operation fails
+ */
+ private void performMultipartCopy(String sourceBucket, String sourceKey, String targetBucket, String targetKey,
+ BlobPath targetPath, long objectSize) throws BlobStoreException
+ {
+ S3MultipartUploadHelper uploadHelper = null;
+ boolean success = false;
+ try {
+ S3Client s3Client = this.clientManager.getS3Client();
+
+ // Retrieve source object metadata
+ HeadObjectRequest headRequest = HeadObjectRequest.builder()
+ .bucket(sourceBucket)
+ .key(sourceKey)
+ .build();
+ HeadObjectResponse headResponse = s3Client.headObject(headRequest);
+ Map metadata = headResponse.metadata();
+
+ // Step 1: Initialize multipart upload with metadata
+ uploadHelper = new S3MultipartUploadHelper(
+ targetBucket,
+ targetKey,
+ s3Client,
+ targetPath,
+ List.of(BlobDoesNotExistCondition.INSTANCE),
+ metadata
+ );
+
+ this.logger.debug("Initiated multipart copy with upload ID: {}", uploadHelper.getUploadId());
+
+ // Step 2: Copy parts using configured part size
+ // Use the configured multipart copy size for the size of copy parts.
+ long partSizeBytes = this.configuration.getS3MultipartCopySizeBytes();
+ long bytePosition = 0;
+
+ while (bytePosition < objectSize) {
+ long lastByte = Math.min(bytePosition + partSizeBytes - 1, objectSize - 1);
+ String copySourceRange = "bytes=%d-%d".formatted(bytePosition, lastByte);
+
+ int partNumber = uploadHelper.getNextPartNumber();
+
+ UploadPartCopyRequest uploadPartCopyRequest = UploadPartCopyRequest.builder()
+ .sourceBucket(sourceBucket)
+ .sourceKey(sourceKey)
+ .destinationBucket(targetBucket)
+ .destinationKey(targetKey)
+ .uploadId(uploadHelper.getUploadId())
+ .partNumber(partNumber)
+ .copySourceRange(copySourceRange)
+ .build();
+
+ UploadPartCopyResponse uploadPartCopyResponse = s3Client.uploadPartCopy(uploadPartCopyRequest);
+
+ uploadHelper.addCompletedPart(uploadPartCopyResponse.copyPartResult().eTag());
+
+ this.logger.debug("Copied part {} (bytes {}-{})", partNumber, bytePosition, lastByte);
+
+ bytePosition += partSizeBytes;
+ }
+
+ // Step 3: Complete multipart upload
+ uploadHelper.complete();
+
+ this.logger.debug("Completed multipart copy for key: {}", targetKey);
+
+ success = true;
+ } catch (Exception e) {
+ throw new BlobStoreException("Failed to perform multipart copy", e);
+ } finally {
+ // Abort the multipart upload on any kind of failure
+ if (!success && uploadHelper != null) {
+ uploadHelper.abort();
+ }
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3DeleteOperations.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3DeleteOperations.java
new file mode 100644
index 0000000000..a71cdcd84c
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3DeleteOperations.java
@@ -0,0 +1,143 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import org.xwiki.component.annotation.Component;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
+import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
+import software.amazon.awssdk.services.s3.model.S3Error;
+
+/**
+ * Handles delete operations for S3 blob store.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+@Component(roles = S3DeleteOperations.class)
+@Singleton
+public class S3DeleteOperations
+{
+ private static final int BATCH_DELETE_SIZE = 1000;
+
+ private static final String NULL = "null";
+
+ @Inject
+ private S3ClientManager s3ClientManager;
+
+ /**
+ * Delete a single blob.
+ *
+ * @param path the blob path
+ * @param store the S3 blob store
+ * @throws BlobStoreException if the delete operation fails
+ */
+ public void deleteBlob(S3BlobStore store, BlobPath path) throws BlobStoreException
+ {
+ String s3Key = store.getKeyMapper().buildS3Key(path);
+
+ try {
+ this.s3ClientManager.getS3Client().deleteObject(DeleteObjectRequest.builder()
+ .bucket(store.getBucketName())
+ .key(s3Key)
+ .build());
+ } catch (Exception e) {
+ throw new BlobStoreException("Failed to delete blob: " + path, e);
+ }
+ }
+
+ /**
+ * Delete all blobs under a given path.
+ *
+ * @param blobs the stream of blobs to delete
+ * @param store the S3 blob store
+ * @throws BlobStoreException if the delete operation fails
+ */
+ public void deleteBlobs(S3BlobStore store, Stream blobs) throws BlobStoreException
+ {
+ List keysToDelete = new ArrayList<>();
+ List partialErrors = new ArrayList<>();
+
+ for (Blob blob : (Iterable) blobs::iterator) {
+ keysToDelete.add(store.getKeyMapper().buildS3Key(blob.getPath()));
+ if (keysToDelete.size() >= BATCH_DELETE_SIZE) {
+ partialErrors.addAll(batchDeleteKeys(store.getBucketName(), keysToDelete));
+ keysToDelete.clear();
+ }
+ }
+ if (!keysToDelete.isEmpty()) {
+ partialErrors.addAll(batchDeleteKeys(store.getBucketName(), keysToDelete));
+ }
+
+ if (!partialErrors.isEmpty()) {
+ String errorsDescription = partialErrors.stream()
+ .map(error -> String.format("key='%s', code='%s', message='%s'",
+ Objects.toString(error.key(), NULL),
+ Objects.toString(error.code(), NULL),
+ Objects.toString(error.message(), NULL)))
+ .collect(Collectors.joining("; "));
+ throw new BlobStoreException("Failed to delete some blobs: " + errorsDescription);
+ }
+ }
+
+ /**
+ * Batch delete a list of S3 keys.
+ *
+ * @param bucketName the S3 bucket name
+ * @param s3Keys the list of S3 keys to delete (max 1000)
+ * @return the list of partial errors returned by S3 (empty if none)
+ * @throws BlobStoreException if the batch delete operation fails completely
+ */
+ private List batchDeleteKeys(String bucketName, List s3Keys) throws BlobStoreException
+ {
+ if (s3Keys.size() > BATCH_DELETE_SIZE) {
+ throw new IllegalArgumentException("Can only delete up to 1000 keys at a time");
+ }
+
+ try {
+ DeleteObjectsRequest deleteRequest = DeleteObjectsRequest.builder()
+ .bucket(bucketName)
+ .delete(b -> b.objects(s3Keys.stream().map(key ->
+ ObjectIdentifier.builder().key(key).build()
+ ).toList()))
+ .build();
+
+ DeleteObjectsResponse deleteObjectsResponse =
+ this.s3ClientManager.getS3Client().deleteObjects(deleteRequest);
+ return deleteObjectsResponse.errors();
+ } catch (Exception e) {
+ throw new BlobStoreException("Failed to batch delete blobs", e);
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3KeyMapper.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3KeyMapper.java
new file mode 100644
index 0000000000..e9d8aeca2d
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3KeyMapper.java
@@ -0,0 +1,150 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xwiki.store.blob.BlobPath;
+
+/**
+ * Utility class for converting between BlobPath and S3 keys.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+class S3KeyMapper
+{
+ private static final Logger LOGGER = LoggerFactory.getLogger(S3KeyMapper.class);
+
+ private static final String PATH_SEPARATOR = "/";
+
+ private final String keyPrefix;
+
+ /**
+ * Constructor.
+ *
+ * @param keyPrefix the key prefix for all objects (can be null or empty)
+ */
+ S3KeyMapper(String keyPrefix)
+ {
+ this.keyPrefix = normalizePrefix(keyPrefix);
+ }
+
+ private static String normalizePrefix(String prefix)
+ {
+ if (StringUtils.isBlank(prefix)) {
+ return "";
+ }
+ String normalized = prefix;
+ normalized = StringUtils.strip(normalized);
+ normalized = StringUtils.strip(normalized, PATH_SEPARATOR);
+
+ return normalized;
+ }
+
+ /**
+ * Build the S3 key from a BlobPath.
+ *
+ * @param blobPath the blob path
+ * @return the S3 key
+ */
+ String buildS3Key(BlobPath blobPath)
+ {
+ String pathStr = blobPath.toString();
+ if (StringUtils.isNotBlank(this.keyPrefix)) {
+ return this.keyPrefix + PATH_SEPARATOR + pathStr;
+ }
+ return pathStr;
+ }
+
+ /**
+ * Get the S3 key prefix for a given blob path (with trailing separator).
+ *
+ * @param path the blob path
+ * @return the S3 key prefix
+ */
+ String getS3KeyPrefix(BlobPath path)
+ {
+ String prefix = buildS3Key(path);
+ if (!prefix.endsWith(PATH_SEPARATOR)) {
+ prefix += PATH_SEPARATOR;
+ }
+ return prefix;
+ }
+
+ /**
+ * Convert an S3 key back to a BlobPath.
+ *
+ * @param s3Key the S3 key
+ * @return the BlobPath, or null if the key doesn't match our prefix
+ */
+ BlobPath s3KeyToBlobPath(String s3Key)
+ {
+ String pathStr = s3Key;
+
+ if (StringUtils.isNotBlank(this.keyPrefix)) {
+ String expectedPrefix = this.keyPrefix + PATH_SEPARATOR;
+ if (s3Key.startsWith(expectedPrefix)) {
+ pathStr = s3Key.substring(expectedPrefix.length());
+ } else {
+ // Key doesn't match our prefix
+ return null;
+ }
+ }
+
+ try {
+ return BlobPath.from(pathStr);
+ } catch (IllegalArgumentException e) {
+ LOGGER.warn("Invalid blob path from S3 key: {}", s3Key, e);
+ return null;
+ }
+ }
+
+ /**
+ * @return the key prefix
+ */
+ public String getKeyPrefix()
+ {
+ return this.keyPrefix;
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof S3KeyMapper that)) {
+ return false;
+ }
+
+ return new EqualsBuilder().append(this.keyPrefix, that.keyPrefix).isEquals();
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return new HashCodeBuilder(17, 37).append(this.keyPrefix).toHashCode();
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3MultipartUploadHelper.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3MultipartUploadHelper.java
new file mode 100644
index 0000000000..e451da546a
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/java/org/xwiki/store/blob/internal/S3MultipartUploadHelper.java
@@ -0,0 +1,316 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.WriteCondition;
+import org.xwiki.store.blob.WriteConditionFailedException;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompletedPart;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * Helper class for managing S3 multipart uploads with proper error handling and cleanup.
+ * This class handles the lifecycle of a multipart upload including initialization, part tracking,
+ * completion, and cleanup on failure.
+ *
+ * @version $Id$
+ * @since 17.10.0RC1
+ */
+public class S3MultipartUploadHelper
+{
+ /**
+ * AWS S3 maximum number of parts in a multipart upload.
+ */
+ public static final int MAX_PARTS = 10000;
+
+ /**
+ * Minimum part size for multipart uploads (5MB as per AWS requirement).
+ */
+ public static final int MIN_PART_SIZE = 5 * 1024 * 1024;
+
+ /**
+ * Maximum part size for multipart uploads (5GB as per AWS requirement).
+ */
+ public static final long MAX_PART_SIZE = 5L * 1024 * 1024 * 1024;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(S3MultipartUploadHelper.class);
+
+ private static final String WILDCARD = "*";
+
+ private final String bucketName;
+
+ private final String s3Key;
+
+ private final S3Client s3Client;
+
+ private final BlobPath blobPath;
+
+ private final List writeConditions;
+
+ private final String uploadId;
+
+ private final List completedParts;
+
+ private int nextPartNumber;
+
+ private boolean completed;
+
+ private boolean aborted;
+
+ /**
+ * Constructor. Initializes the multipart upload immediately.
+ *
+ * @param bucketName the S3 bucket name
+ * @param s3Key the S3 key for the object
+ * @param s3Client the S3 client
+ * @param blobPath the blob path (for error reporting)
+ * @param writeConditions optional write conditions to enforce
+ * @throws IOException if initialization fails
+ */
+ public S3MultipartUploadHelper(String bucketName, String s3Key, S3Client s3Client, BlobPath blobPath,
+ List writeConditions) throws IOException
+ {
+ this(bucketName, s3Key, s3Client, blobPath, writeConditions, null);
+ }
+
+ /**
+ * Constructor. Initializes the multipart upload immediately with metadata.
+ *
+ * @param bucketName the S3 bucket name
+ * @param s3Key the S3 key for the object
+ * @param s3Client the S3 client
+ * @param blobPath the blob path (for error reporting)
+ * @param writeConditions optional write conditions to enforce
+ * @param metadata optional metadata to apply to the object
+ * @throws IOException if initialization fails
+ */
+ public S3MultipartUploadHelper(String bucketName, String s3Key, S3Client s3Client, BlobPath blobPath,
+ List writeConditions, Map metadata) throws IOException
+ {
+ this.bucketName = bucketName;
+ this.s3Key = s3Key;
+ this.s3Client = s3Client;
+ this.blobPath = blobPath;
+ this.writeConditions = writeConditions;
+ this.completedParts = new ArrayList<>();
+ this.nextPartNumber = 1;
+ this.completed = false;
+ this.aborted = false;
+
+ // Initialize the multipart upload immediately.
+ try {
+ CreateMultipartUploadRequest.Builder requestBuilder = CreateMultipartUploadRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key);
+
+ // Add metadata if provided.
+ if (metadata != null && !metadata.isEmpty()) {
+ requestBuilder.metadata(metadata);
+ }
+
+ CreateMultipartUploadRequest createRequest = requestBuilder.build();
+ CreateMultipartUploadResponse response = this.s3Client.createMultipartUpload(createRequest);
+ this.uploadId = response.uploadId();
+
+ LOGGER.debug("Initialized multipart upload for key {} with upload ID: {}", this.s3Key, this.uploadId);
+ } catch (Exception e) {
+ throw new IOException("Failed to initialize multipart upload for blob at path " + this.blobPath, e);
+ }
+ }
+
+ /**
+ * Get the next part number and ensure we haven't exceeded the maximum number of parts.
+ * This method should be called before uploading each part.
+ *
+ * @return the next part number to use (1-based)
+ * @throws IOException if the maximum number of parts has been exceeded
+ */
+ public int getNextPartNumber() throws IOException
+ {
+ ensureNotCompleted();
+ ensureNotAborted();
+
+ if (this.nextPartNumber > MAX_PARTS) {
+ throw new IOException(String.format(
+ "Exceeded maximum number of parts (%d) for multipart upload. "
+ + "Consider increasing the part size to reduce the number of parts.", MAX_PARTS));
+ }
+
+ return this.nextPartNumber;
+ }
+
+ /**
+ * Add a completed part to the upload. The part number is tracked internally.
+ *
+ * @param eTag the ETag returned from the upload
+ * @throws IOException if the upload is in an invalid state
+ */
+ public void addCompletedPart(String eTag) throws IOException
+ {
+ ensureNotCompleted();
+ ensureNotAborted();
+
+ CompletedPart completedPart = CompletedPart.builder()
+ .partNumber(this.nextPartNumber)
+ .eTag(eTag)
+ .build();
+
+ this.completedParts.add(completedPart);
+
+ LOGGER.debug("Added completed part {} for upload ID: {}", this.nextPartNumber, this.uploadId);
+
+ this.nextPartNumber++;
+ }
+
+ /**
+ * Complete the multipart upload.
+ *
+ * @throws IOException if completion fails
+ */
+ public void complete() throws IOException
+ {
+ complete(null);
+ }
+
+ /**
+ * Complete the multipart upload with a custom configuration.
+ * This allows callers to add additional settings to the complete request.
+ *
+ * @param requestCustomizer a consumer to customize the complete request builder
+ * @throws IOException if completion fails
+ */
+ public void complete(Consumer requestCustomizer) throws IOException
+ {
+ ensureNotCompleted();
+ ensureNotAborted();
+
+ try {
+ CompleteMultipartUploadRequest.Builder builder = CompleteMultipartUploadRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key)
+ .uploadId(this.uploadId)
+ .multipartUpload(b -> b.parts(this.completedParts));
+
+ // Add conditional headers if needed
+ if (hasIfNotExistsCondition()) {
+ builder.ifNoneMatch(WILDCARD);
+ }
+
+ // Allow the caller to customize the request.
+ if (requestCustomizer != null) {
+ requestCustomizer.accept(builder);
+ }
+
+ CompleteMultipartUploadRequest completeRequest = builder.build();
+ this.s3Client.completeMultipartUpload(completeRequest);
+
+ this.completed = true;
+
+ LOGGER.debug("Completed multipart upload for key {} with upload ID: {}", this.s3Key, this.uploadId);
+ } catch (S3Exception e) {
+ handleS3Exception(e);
+ } catch (Exception e) {
+ throw new IOException("Failed to complete multipart upload for blob at path " + this.blobPath, e);
+ }
+ }
+
+ /**
+ * Abort the multipart upload and clean up any uploaded parts.
+ * This method is idempotent and safe to call multiple times.
+ */
+ public void abort()
+ {
+ if (this.aborted) {
+ return;
+ }
+
+ try {
+ AbortMultipartUploadRequest abortRequest = AbortMultipartUploadRequest.builder()
+ .bucket(this.bucketName)
+ .key(this.s3Key)
+ .uploadId(this.uploadId)
+ .build();
+
+ this.s3Client.abortMultipartUpload(abortRequest);
+ this.aborted = true;
+
+ LOGGER.debug("Aborted multipart upload for key {} with upload ID: {}", this.s3Key, this.uploadId);
+ } catch (Exception e) {
+ // Log but don't throw - abort is best-effort cleanup
+ LOGGER.warn("Failed to abort multipart upload for blob at path {} with upload ID {}, root cause: {}",
+ this.blobPath, this.uploadId, ExceptionUtils.getRootCauseMessage(e));
+ }
+ }
+
+ /**
+ * Get the upload ID.
+ *
+ * @return the upload ID
+ */
+ public String getUploadId()
+ {
+ return this.uploadId;
+ }
+
+ private void handleS3Exception(S3Exception e) throws IOException
+ {
+ // Check if this is a precondition failed error (412) for conditional requests.
+ if (e.statusCode() == 412 && hasIfNotExistsCondition()) {
+ throw new IOException("Write condition failed - blob already exists",
+ new WriteConditionFailedException(this.blobPath, this.writeConditions, e));
+ }
+ throw new IOException("S3 operation failed for blob at path " + this.blobPath, e);
+ }
+
+ private boolean hasIfNotExistsCondition()
+ {
+ return this.writeConditions != null && this.writeConditions.contains(BlobDoesNotExistCondition.INSTANCE);
+ }
+
+ private void ensureNotCompleted() throws IOException
+ {
+ if (this.completed) {
+ throw new IOException("Multipart upload already completed");
+ }
+ }
+
+ private void ensureNotAborted() throws IOException
+ {
+ if (this.aborted) {
+ throw new IOException("Multipart upload has been aborted");
+ }
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/resources/META-INF/components.txt b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/resources/META-INF/components.txt
new file mode 100644
index 0000000000..7b8d792029
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/main/resources/META-INF/components.txt
@@ -0,0 +1,6 @@
+org.xwiki.store.blob.internal.S3BlobStore
+org.xwiki.store.blob.internal.S3BlobStoreConfiguration
+org.xwiki.store.blob.internal.S3BlobStoreManager
+org.xwiki.store.blob.internal.S3CopyOperations
+org.xwiki.store.blob.internal.S3DeleteOperations
+org.xwiki.store.blob.internal.S3ClientManager
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobIteratorTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobIteratorTest.java
new file mode 100644
index 0000000000..46e1afd9d6
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobIteratorTest.java
@@ -0,0 +1,358 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3BlobIterator}.
+ *
+ * @version $Id$
+ */
+@ExtendWith(MockitoExtension.class)
+class S3BlobIteratorTest
+{
+ private static final String BUCKET_NAME = "test-bucket";
+
+ private static final String PREFIX = "test/prefix/";
+
+ private static final int PAGE_SIZE = 10;
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private S3BlobStore store;
+
+ @Mock
+ private S3KeyMapper keyMapper;
+
+ private S3BlobIterator iterator;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.iterator = new S3BlobIterator(PREFIX, BUCKET_NAME, PAGE_SIZE, this.s3Client, this.store);
+ lenient().when(this.store.getKeyMapper()).thenReturn(this.keyMapper);
+ }
+
+ @Test
+ void iterateWithSinglePage()
+ {
+ // Create mock S3 objects
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ S3Object obj2 = createS3Object("test/prefix/file2.txt");
+
+ // Mock the response
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(obj1, obj2)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ // Mock blob path conversion
+ BlobPath path1 = mock();
+ BlobPath path2 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file2.txt")).thenReturn(path2);
+
+ // Verify iteration
+ assertTrue(this.iterator.hasNext());
+ Blob blob1 = this.iterator.next();
+ assertNotNull(blob1);
+ assertEquals(path1, blob1.getPath());
+
+ assertTrue(this.iterator.hasNext());
+ Blob blob2 = this.iterator.next();
+ assertNotNull(blob2);
+ assertEquals(path2, blob2.getPath());
+
+ assertFalse(this.iterator.hasNext());
+
+ // Verify S3 client was called once
+ verify(this.s3Client, times(1)).listObjectsV2(any(ListObjectsV2Request.class));
+ }
+
+ @Test
+ void iterateWithMultiplePages()
+ {
+ // First page
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ ListObjectsV2Response response1 = ListObjectsV2Response.builder()
+ .contents(obj1)
+ .isTruncated(true)
+ .nextContinuationToken("token1")
+ .build();
+
+ // Second page
+ S3Object obj2 = createS3Object("test/prefix/file2.txt");
+ ListObjectsV2Response response2 = ListObjectsV2Response.builder()
+ .contents(obj2)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
+ .thenReturn(response1)
+ .thenReturn(response2);
+
+ // Mock blob path conversion
+ BlobPath path1 = mock();
+ BlobPath path2 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file2.txt")).thenReturn(path2);
+
+ // Verify iteration
+ assertTrue(this.iterator.hasNext());
+ Blob blob1 = this.iterator.next();
+ assertNotNull(blob1);
+
+ assertTrue(this.iterator.hasNext());
+ Blob blob2 = this.iterator.next();
+ assertNotNull(blob2);
+
+ assertFalse(this.iterator.hasNext());
+
+ // Verify S3 client was called twice
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client, times(2)).listObjectsV2(captor.capture());
+
+ List requests = captor.getAllValues();
+ assertEquals(2, requests.size());
+ assertEquals(PREFIX, requests.get(0).prefix());
+ assertEquals(BUCKET_NAME, requests.get(0).bucket());
+ assertEquals(PAGE_SIZE, requests.get(0).maxKeys());
+ assertEquals("token1", requests.get(1).continuationToken());
+ }
+
+ @Test
+ void skipDirectories()
+ {
+ // Create mock S3 objects including a directory
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ S3Object directory = createS3Object("test/prefix/subdir/");
+ S3Object obj2 = createS3Object("test/prefix/file2.txt");
+
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(obj1, directory, obj2)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ // Mock blob path conversion
+ BlobPath path1 = mock();
+ BlobPath path2 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file2.txt")).thenReturn(path2);
+
+ // Verify iteration - directory should be skipped
+ List blobs = new ArrayList<>();
+ while (this.iterator.hasNext()) {
+ blobs.add(this.iterator.next());
+ }
+
+ assertEquals(2, blobs.size());
+ assertEquals(path1, blobs.get(0).getPath());
+ assertEquals(path2, blobs.get(1).getPath());
+ }
+
+ @Test
+ void skipInvalidBlobPaths()
+ {
+ // Create mock S3 objects
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ S3Object obj2 = createS3Object("test/prefix/invalid.txt");
+ S3Object obj3 = createS3Object("test/prefix/file3.txt");
+
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(obj1, obj2, obj3)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ // Mock blob path conversion - obj2 returns null (invalid path)
+ BlobPath path1 = mock();
+ BlobPath path3 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/invalid.txt")).thenReturn(null);
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file3.txt")).thenReturn(path3);
+
+ // Verify iteration - invalid path should be skipped
+ List blobs = new ArrayList<>();
+ while (this.iterator.hasNext()) {
+ blobs.add(this.iterator.next());
+ }
+
+ assertEquals(2, blobs.size());
+ assertEquals(path1, blobs.get(0).getPath());
+ assertEquals(path3, blobs.get(1).getPath());
+ }
+
+ @Test
+ void emptyResults()
+ {
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(List.of())
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ assertFalse(this.iterator.hasNext());
+ assertThrows(NoSuchElementException.class, () -> this.iterator.next());
+ }
+
+ @Test
+ void nextWithoutHasNext()
+ {
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(obj1)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ BlobPath path1 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+
+ // Call next() without hasNext()
+ Blob blob = this.iterator.next();
+ assertNotNull(blob);
+ assertEquals(path1, blob.getPath());
+
+ assertThrows(NoSuchElementException.class, () -> this.iterator.next());
+ }
+
+ @Test
+ void multipleHasNextCalls()
+ {
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ ListObjectsV2Response response = ListObjectsV2Response.builder()
+ .contents(obj1)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response);
+
+ BlobPath path1 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+
+ // Multiple hasNext() calls should not advance the iterator
+ assertTrue(this.iterator.hasNext());
+ assertTrue(this.iterator.hasNext());
+ assertTrue(this.iterator.hasNext());
+
+ Blob blob = this.iterator.next();
+ assertNotNull(blob);
+
+ assertFalse(this.iterator.hasNext());
+ assertFalse(this.iterator.hasNext());
+
+ // Verify S3 client was called only once
+ verify(this.s3Client, times(1)).listObjectsV2(any(ListObjectsV2Request.class));
+ }
+
+ @Test
+ void s3ExceptionDuringFetch()
+ {
+ AwsServiceException exception = S3Exception.builder()
+ .message("Access denied")
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenThrow(exception);
+
+ assertThrows(S3Exception.class, () -> this.iterator.hasNext());
+ }
+
+ @Test
+ void emptyPageFollowedByNonEmptyPage()
+ {
+ // First page is empty but truncated
+ ListObjectsV2Response response1 = ListObjectsV2Response.builder()
+ .contents(List.of())
+ .isTruncated(true)
+ .nextContinuationToken("token1")
+ .build();
+
+ // Second page has content
+ S3Object obj1 = createS3Object("test/prefix/file1.txt");
+ ListObjectsV2Response response2 = ListObjectsV2Response.builder()
+ .contents(obj1)
+ .isTruncated(false)
+ .build();
+
+ when(this.s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
+ .thenReturn(response1)
+ .thenReturn(response2);
+
+ BlobPath path1 = mock();
+ when(this.keyMapper.s3KeyToBlobPath("test/prefix/file1.txt")).thenReturn(path1);
+
+ assertTrue(this.iterator.hasNext());
+ Blob blob = this.iterator.next();
+ assertNotNull(blob);
+ assertEquals(path1, blob.getPath());
+
+ assertFalse(this.iterator.hasNext());
+
+ // Verify both pages were fetched
+ verify(this.s3Client, times(2)).listObjectsV2(any(ListObjectsV2Request.class));
+ }
+
+ private S3Object createS3Object(String key)
+ {
+ return S3Object.builder()
+ .key(key)
+ .build();
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobOutputStreamTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobOutputStreamTest.java
new file mode 100644
index 0000000000..d70cf6a9a8
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobOutputStreamTest.java
@@ -0,0 +1,564 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.WriteCondition;
+import org.xwiki.store.blob.WriteConditionFailedException;
+
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.UploadPartRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartResponse;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3BlobOutputStream}.
+ *
+ * @version $Id$
+ */
+@ExtendWith(MockitoExtension.class)
+class S3BlobOutputStreamTest
+{
+ private static final String BUCKET_NAME = "test-bucket";
+
+ private static final String S3_KEY = "test-key";
+
+ private static final String UPLOAD_ID = "test-upload-id";
+
+ private static final String ETAG = "test-etag";
+
+ // 5MB
+ private static final int PART_SIZE = 5 * 1024 * 1024;
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private BlobPath blobPath;
+
+ private List writeConditions;
+
+ private final List capturedUploadPartData = new ArrayList<>();
+
+ private byte[] capturedPutObjectData;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.writeConditions = new ArrayList<>();
+ this.capturedUploadPartData.clear();
+ this.capturedPutObjectData = null;
+
+ lenient().when(this.s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenAnswer(invocation -> {
+ assertNull(this.capturedPutObjectData, "putObject called multiple times");
+ RequestBody body = invocation.getArgument(1);
+ try (InputStream stream = body.contentStreamProvider().newStream()) {
+ this.capturedPutObjectData = stream.readAllBytes();
+ }
+ return null;
+ });
+
+ lenient().when(this.s3Client.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+ .thenAnswer(this::mockedUploadPart);
+ }
+
+ private UploadPartResponse mockedUploadPart(InvocationOnMock invocation) throws IOException
+ {
+ RequestBody body = invocation.getArgument(1);
+ try (InputStream stream = body.contentStreamProvider().newStream()) {
+ this.capturedUploadPartData.add(stream.readAllBytes());
+ }
+ return UploadPartResponse.builder().eTag(ETAG).build();
+ }
+
+ /**
+ * Helper method to fill a byte array with deterministic but non-repeating data.
+ * Uses a seeded Random to create a pseudo-random pattern that is reproducible
+ * across test runs but doesn't repeat within reasonable test data sizes.
+ *
+ * @param data the byte array to fill
+ */
+ private void fillArray(byte[] data)
+ {
+ Random random = new Random(0x123456789ABCDEFL);
+ random.nextBytes(data);
+ }
+
+ @Test
+ void writeSmallFileWithSimpleUpload() throws IOException
+ {
+ // Test simple upload for files smaller than the multipart threshold
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Hello, World!".getBytes();
+ outputStream.write(data);
+ outputStream.close();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
+ verify(this.s3Client, never()).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+
+ PutObjectRequest request = requestCaptor.getValue();
+ assertEquals(BUCKET_NAME, request.bucket());
+ assertEquals(S3_KEY, request.key());
+ assertNull(request.ifMatch());
+ assertNull(request.ifNoneMatch());
+
+ assertArrayEquals(data, this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeLargeFileWithMultipartUpload() throws IOException
+ {
+ // Test multipart upload for files larger than the threshold
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ // Write more than one part of data.
+ byte[] data = new byte[PART_SIZE + 1000];
+ fillArray(data);
+ outputStream.write(data);
+ outputStream.close();
+
+ verify(this.s3Client).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+ verify(this.s3Client, times(2)).uploadPart(any(UploadPartRequest.class), any(RequestBody.class));
+ ArgumentCaptor completeCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(completeCaptor.capture());
+
+ CompleteMultipartUploadRequest completeRequest = completeCaptor.getValue();
+ assertEquals(BUCKET_NAME, completeRequest.bucket());
+ assertEquals(S3_KEY, completeRequest.key());
+ assertEquals(UPLOAD_ID, completeRequest.uploadId());
+ assertNull(completeRequest.ifMatch());
+ assertNull(completeRequest.ifNoneMatch());
+
+ assertEquals(2, this.capturedUploadPartData.size());
+ assertArrayEquals(Arrays.copyOfRange(data, 0, PART_SIZE), this.capturedUploadPartData.get(0));
+ assertArrayEquals(Arrays.copyOfRange(data, PART_SIZE, data.length), this.capturedUploadPartData.get(1));
+ }
+
+ @Test
+ void writeMultiplePartsWithoutRemainder() throws IOException
+ {
+ // Test multipart upload with 3 full parts
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ // 3 full parts
+ byte[] data = new byte[PART_SIZE * 3];
+ fillArray(data);
+ outputStream.write(data);
+ outputStream.close();
+
+ verify(this.s3Client).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+ verify(this.s3Client, times(3)).uploadPart(any(UploadPartRequest.class), any(RequestBody.class));
+ verify(this.s3Client).completeMultipartUpload(any(CompleteMultipartUploadRequest.class));
+
+ assertEquals(3, this.capturedUploadPartData.size());
+ assertArrayEquals(Arrays.copyOfRange(data, 0, PART_SIZE), this.capturedUploadPartData.get(0));
+ assertArrayEquals(Arrays.copyOfRange(data, PART_SIZE, PART_SIZE * 2), this.capturedUploadPartData.get(1));
+ assertArrayEquals(Arrays.copyOfRange(data, PART_SIZE * 2, PART_SIZE * 3), this.capturedUploadPartData.get(2));
+ }
+
+ @Test
+ void writeSingleByte() throws IOException
+ {
+ // Test writing single bytes
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ // 'A'
+ outputStream.write(65);
+ // 'B'
+ outputStream.write(66);
+ outputStream.close();
+
+ verify(this.s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+
+ assertArrayEquals(new byte[]{65, 66}, this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeWithOffset() throws IOException
+ {
+ // Test writing with offset and length parameters
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Hello, World!".getBytes();
+ // Write "World"
+ outputStream.write(data, 7, 5);
+ outputStream.close();
+
+ verify(this.s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+
+ assertArrayEquals("World".getBytes(), this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeEmptyStream() throws IOException
+ {
+ // Test closing an empty stream
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ outputStream.close();
+
+ verify(this.s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ verifyNoMoreInteractions(this.s3Client);
+
+ assertArrayEquals(new byte[0], this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeWithBlobDoesNotExistCondition() throws IOException
+ {
+ // Test conditional write with BlobDoesNotExistCondition
+ this.writeConditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Test data".getBytes();
+ outputStream.write(data);
+ outputStream.close();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
+
+ PutObjectRequest request = requestCaptor.getValue();
+ assertEquals("*", request.ifNoneMatch());
+
+ assertArrayEquals(data, this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeWithBlobDoesNotExistConditionMultipart() throws IOException
+ {
+ // Test conditional write with multipart upload
+ this.writeConditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = new byte[PART_SIZE + 1000];
+ fillArray(data);
+ outputStream.write(data);
+ outputStream.close();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(requestCaptor.capture());
+
+ CompleteMultipartUploadRequest request = requestCaptor.getValue();
+ assertEquals("*", request.ifNoneMatch());
+
+ assertEquals(2, this.capturedUploadPartData.size());
+ assertArrayEquals(Arrays.copyOfRange(data, 0, PART_SIZE), this.capturedUploadPartData.get(0));
+ assertArrayEquals(Arrays.copyOfRange(data, PART_SIZE, data.length), this.capturedUploadPartData.get(1));
+ }
+
+ @Test
+ void writeConditionFailedSimpleUpload() throws IOException
+ {
+ // Test write condition failure for simple upload
+ this.writeConditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ S3Exception s3Exception = (S3Exception) S3Exception.builder()
+ .statusCode(412)
+ .message("Precondition Failed")
+ .build();
+ when(this.s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenThrow(s3Exception);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Test data".getBytes();
+ outputStream.write(data);
+
+ IOException exception = assertThrows(IOException.class, outputStream::close);
+ assertTrue(exception.getMessage().contains("Write condition failed"));
+ assertInstanceOf(WriteConditionFailedException.class, exception.getCause());
+
+ // Verify that the condition was actually checked.
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
+ PutObjectRequest request = requestCaptor.getValue();
+ assertEquals("*", request.ifNoneMatch());
+ }
+
+ @Test
+ void writeConditionFailedMultipartUpload() throws IOException
+ {
+ // Test write condition failure for multipart upload
+ this.writeConditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ S3Exception s3Exception = (S3Exception) S3Exception.builder()
+ .statusCode(412)
+ .message("Precondition Failed")
+ .build();
+ when(this.s3Client.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
+ .thenThrow(s3Exception);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = new byte[PART_SIZE + 1000];
+ outputStream.write(data);
+
+ IOException exception = assertThrows(IOException.class, outputStream::close);
+ assertTrue(exception.getMessage().contains("Write condition failed"));
+ assertInstanceOf(WriteConditionFailedException.class, exception.getCause());
+
+ // Verify that the condition was actually checked.
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(requestCaptor.capture());
+ CompleteMultipartUploadRequest request = requestCaptor.getValue();
+ assertEquals("*", request.ifNoneMatch());
+ }
+
+ @Test
+ void multipartUploadAbortedOnError()
+ {
+ // Test that multipart upload is aborted when an error occurs
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ when(this.s3Client.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+ .thenThrow(new RuntimeException("Upload failed"));
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = new byte[PART_SIZE + 1000];
+ assertThrows(IOException.class, () -> outputStream.write(data));
+
+ verify(this.s3Client).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
+
+ assertDoesNotThrow(outputStream::close);
+ }
+
+ @Test
+ void multipartUploadNotAbortedIfCreateFails()
+ {
+ // Test that multipart upload is not aborted if creation fails
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenThrow(new RuntimeException("Creation failed"));
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = new byte[PART_SIZE + 1000];
+ assertThrows(IOException.class, () -> outputStream.write(data));
+
+ verify(this.s3Client, never()).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
+ }
+
+ @Test
+ void multipartUploadAbortedWhenSecondWriteFails() throws IOException
+ {
+ // Test that multipart upload is aborted when the second write fails
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ when(this.s3Client.uploadPart(any(UploadPartRequest.class), any(RequestBody.class)))
+ .thenAnswer(this::mockedUploadPart)
+ .thenThrow(new RuntimeException("Second upload failed"));
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ // First write should succeed.
+ byte[] firstPart = new byte[PART_SIZE + 1000];
+ outputStream.write(firstPart);
+
+ // Close should fail due to second upload failure.
+ assertThrows(IOException.class, outputStream::close);
+
+ verify(this.s3Client).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
+ }
+
+ @Test
+ void writeAfterCloseThrowsException() throws IOException
+ {
+ // Test that writing after close throws an exception
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ outputStream.close();
+
+ assertThrows(IOException.class, () -> outputStream.write(65));
+ assertThrows(IOException.class, () -> outputStream.write(new byte[10]));
+ assertThrows(IOException.class, () -> outputStream.write(new byte[10], 0, 5));
+ }
+
+ @Test
+ void flushDoesNothing() throws IOException
+ {
+ // Test that flush doesn't trigger any S3 operations
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Test data".getBytes();
+ outputStream.write(data);
+ outputStream.flush();
+
+ verify(this.s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ verify(this.s3Client, never()).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+
+ outputStream.close();
+
+ assertArrayEquals(data, this.capturedPutObjectData);
+ }
+
+ @Test
+ void closeMultipleTimesIsSafe() throws IOException
+ {
+ // Test that closing multiple times doesn't cause issues
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Test data".getBytes();
+ outputStream.write(data);
+ outputStream.close();
+ // Second close should be safe
+ outputStream.close();
+
+ verify(this.s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+
+ assertArrayEquals(data, this.capturedPutObjectData);
+ }
+
+ @Test
+ void writeExactlyAtPartSizeBoundary() throws IOException
+ {
+ // Test writing exactly PART_SIZE bytes
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = new byte[PART_SIZE];
+ fillArray(data);
+ outputStream.write(data);
+ outputStream.close();
+
+ verify(this.s3Client).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+ verify(this.s3Client, times(1)).uploadPart(any(UploadPartRequest.class), any(RequestBody.class));
+ verify(this.s3Client).completeMultipartUpload(any(CompleteMultipartUploadRequest.class));
+
+ assertEquals(1, this.capturedUploadPartData.size());
+ assertArrayEquals(data, this.capturedUploadPartData.get(0));
+ }
+
+ @Test
+ void s3ExceptionDuringSimpleUpload() throws IOException
+ {
+ // Test S3 exception during simple upload (non-412 error)
+ S3Exception s3Exception = (S3Exception) S3Exception.builder()
+ .statusCode(500)
+ .message("Internal Server Error")
+ .build();
+ when(this.s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
+ .thenThrow(s3Exception);
+
+ S3BlobOutputStream outputStream = new S3BlobOutputStream(BUCKET_NAME, S3_KEY, this.s3Client,
+ this.writeConditions, this.blobPath, PART_SIZE);
+
+ byte[] data = "Test data".getBytes();
+ outputStream.write(data);
+
+ IOException exception = assertThrows(IOException.class, outputStream::close);
+ assertTrue(exception.getMessage().contains("Failed to upload to S3"));
+ assertEquals(s3Exception, exception.getCause());
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreConfigurationTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreConfigurationTest.java
new file mode 100644
index 0000000000..3738081364
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreConfigurationTest.java
@@ -0,0 +1,133 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Named;
+import org.junit.jupiter.api.Test;
+import org.xwiki.configuration.ConfigurationSource;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3BlobStoreConfiguration}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class S3BlobStoreConfigurationTest
+{
+ private static final String MULTIPART_PROP = "store.s3.multipartPartUploadSizeMB";
+
+ private static final String MULTIPART_COPY_PROP = "store.s3.multipartCopySizeMB";
+
+ @InjectMockComponents
+ private S3BlobStoreConfiguration configuration;
+
+ @MockComponent
+ @Named("xwikiproperties")
+ private ConfigurationSource configurationSource;
+
+ @Test
+ void getS3MultipartPartUploadSizeBytesDefault()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(5);
+
+ assertEquals(S3MultipartUploadHelper.MIN_PART_SIZE, this.configuration.getS3MultipartPartUploadSizeBytes());
+ }
+
+ @Test
+ void getS3MultipartPartUploadSizeBytesInRange()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(10);
+
+ assertEquals(10L * 1024L * 1024L, this.configuration.getS3MultipartPartUploadSizeBytes());
+ }
+
+ @Test
+ void getS3MultipartPartUploadSizeBytesBelowMin()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(1);
+
+ assertEquals(S3MultipartUploadHelper.MIN_PART_SIZE, this.configuration.getS3MultipartPartUploadSizeBytes());
+ }
+
+ @Test
+ void getS3MultipartPartUploadSizeBytesAboveMax()
+ {
+ // 6000 MB is larger than the 5120 MB corresponding to the 5 GB MAX_PART_SIZE
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(6000);
+
+ assertEquals(S3MultipartUploadHelper.MAX_PART_SIZE, this.configuration.getS3MultipartPartUploadSizeBytes());
+ }
+
+ @Test
+ void getS3MultipartCopySizeBytesDefault()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_COPY_PROP, 512)).thenReturn(512);
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(5);
+
+ assertEquals(512L * 1024L * 1024L, this.configuration.getS3MultipartCopySizeBytes());
+ }
+
+ @Test
+ void getS3MultipartCopySizeBytesInRange()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_COPY_PROP, 512)).thenReturn(256);
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(5);
+
+ assertEquals(256L * 1024L * 1024L, this.configuration.getS3MultipartCopySizeBytes());
+ }
+
+ @Test
+ void getS3MultipartCopySizeBytesBelowMin()
+ {
+ when(this.configurationSource.getProperty(MULTIPART_COPY_PROP, 512)).thenReturn(1);
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(5);
+
+ assertEquals(S3MultipartUploadHelper.MIN_PART_SIZE, this.configuration.getS3MultipartCopySizeBytes());
+ }
+
+ @Test
+ void getS3MultipartCopySizeBytesAboveMax()
+ {
+ // 6000 MB is larger than the 5120 MB corresponding to the 5 GB MAX_PART_SIZE
+ when(this.configurationSource.getProperty(MULTIPART_COPY_PROP, 512)).thenReturn(6000);
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(5);
+
+ assertEquals(S3MultipartUploadHelper.MAX_PART_SIZE, this.configuration.getS3MultipartCopySizeBytes());
+ }
+
+ @Test
+ void getS3MultipartCopySizeBytesAtLeastUploadPartSize()
+ {
+ // Configure the copy size to a small value (which would be clamped to MIN_PART_SIZE),
+ // but configure the upload part size to a larger value; the result should be the upload part size.
+ when(this.configurationSource.getProperty(MULTIPART_COPY_PROP, 512)).thenReturn(1);
+ when(this.configurationSource.getProperty(MULTIPART_PROP, 5)).thenReturn(10);
+
+ long expectedUploadPartSize = 10L * 1024L * 1024L;
+ assertEquals(expectedUploadPartSize, this.configuration.getS3MultipartCopySizeBytes());
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreManagerTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreManagerTest.java
new file mode 100644
index 0000000000..c99efb909c
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreManagerTest.java
@@ -0,0 +1,252 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import jakarta.inject.Provider;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.xwiki.component.phase.InitializationException;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.store.blob.BlobStoreManager;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.annotation.ComponentList;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectComponentManager;
+import org.xwiki.test.junit5.mockito.MockComponent;
+import org.xwiki.test.mockito.MockitoComponentManager;
+
+import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
+import software.amazon.awssdk.services.s3.model.HeadBucketResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3BlobStoreManager}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+@ComponentList(S3BlobStoreManager.class)
+class S3BlobStoreManagerTest
+{
+ @InjectComponentManager
+ private MockitoComponentManager componentManager;
+
+ @MockComponent
+ private S3BlobStoreConfiguration configuration;
+
+ @MockComponent
+ private S3ClientManager clientManager;
+
+ @MockComponent
+ private Provider blobStoreProvider;
+
+ @RegisterExtension
+ private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.INFO);
+
+ private S3Client s3Client;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.s3Client = mock();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ }
+
+ @Test
+ void initializeWithValidBucket() throws Exception
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("test-bucket");
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenReturn(HeadBucketResponse.builder().build());
+
+ BlobStoreManager manager = this.componentManager.getInstance(BlobStoreManager.class, "s3");
+
+ assertNotNull(manager);
+ verify(this.s3Client).headBucket(any(HeadBucketRequest.class));
+ assertEquals("S3 blob store manager initialized for bucket: test-bucket",
+ this.logCapture.getMessage(0));
+ }
+
+ @Test
+ void initializeWithMissingBucketName()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("");
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertTrue(exception.getCause().getMessage().contains("S3 bucket name is required but not configured"));
+ assertTrue(exception.getCause().getMessage().contains("store.s3.bucketName"));
+ }
+
+ @Test
+ void initializeWithNullBucketName()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn(null);
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertTrue(exception.getCause().getMessage().contains("S3 bucket name is required but not configured"));
+ }
+
+ @Test
+ void initializeWithNonExistentBucket()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("non-existent-bucket");
+ NoSuchBucketException noSuchBucketException = NoSuchBucketException.builder()
+ .message("Bucket does not exist")
+ .build();
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenThrow(noSuchBucketException);
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertEquals("Failed to validate S3 bucket access", exception.getCause().getMessage());
+ assertInstanceOf(BlobStoreException.class, exception.getCause().getCause());
+ assertTrue(exception.getCause().getCause().getMessage()
+ .contains("S3 bucket does not exist: non-existent-bucket"));
+ }
+
+ @Test
+ void initializeWithAccessDenied()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("forbidden-bucket");
+ AwsServiceException s3Exception = S3Exception.builder()
+ .statusCode(403)
+ .awsErrorDetails(AwsErrorDetails.builder()
+ .errorCode("AccessDenied")
+ .errorMessage("Access Denied")
+ .build())
+ .build();
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenThrow(s3Exception);
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertEquals("Failed to validate S3 bucket access", exception.getCause().getMessage());
+ assertInstanceOf(BlobStoreException.class, exception.getCause().getCause());
+ assertTrue(exception.getCause().getCause().getMessage()
+ .contains("Access denied to S3 bucket: forbidden-bucket"));
+ assertTrue(exception.getCause().getCause().getMessage()
+ .contains("Please check credentials and bucket permissions"));
+ }
+
+ @Test
+ void initializeWithOtherS3Exception()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("error-bucket");
+ AwsServiceException s3Exception = S3Exception.builder()
+ .statusCode(500)
+ .awsErrorDetails(AwsErrorDetails.builder()
+ .errorCode("InternalError")
+ .errorMessage("Internal Server Error")
+ .build())
+ .build();
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenThrow(s3Exception);
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertEquals("Failed to validate S3 bucket access", exception.getCause().getMessage());
+ assertInstanceOf(BlobStoreException.class, exception.getCause().getCause());
+ assertTrue(exception.getCause().getCause().getMessage()
+ .contains("Failed to access S3 bucket: error-bucket"));
+ }
+
+ @Test
+ void initializeWithUnexpectedException()
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("test-bucket");
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenThrow(new RuntimeException("Unexpected error"));
+
+ Exception exception = assertThrows(Exception.class,
+ () -> this.componentManager.getInstance(BlobStoreManager.class, "s3"));
+
+ assertInstanceOf(InitializationException.class, exception.getCause());
+ assertEquals("Failed to validate S3 bucket access", exception.getCause().getMessage());
+ assertInstanceOf(BlobStoreException.class, exception.getCause().getCause());
+ assertTrue(exception.getCause().getCause().getMessage()
+ .contains("Unexpected error while validating S3 bucket access"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'', '', ''",
+ "'', 'mystore', 'mystore'",
+ "'global/prefix', '', 'global/prefix'",
+ "'global/prefix', 'mystore', 'global/prefix/mystore'",
+ "'prefix',, 'prefix'"
+ })
+ void getBlobStoreWithPrefixAndName(String prefix, String name, String keyPrefix) throws Exception
+ {
+ BlobStoreManager manager = initializeManager();
+ when(this.configuration.getS3KeyPrefix()).thenReturn(prefix);
+
+ S3BlobStore store = mock();
+ when(this.blobStoreProvider.get()).thenReturn(store);
+
+ BlobStore blobStore = manager.getBlobStore(name);
+
+ assertSame(store, blobStore);
+ verify(store).initialize(name, "test-bucket", keyPrefix);
+ }
+
+ private BlobStoreManager initializeManager() throws Exception
+ {
+ when(this.configuration.getS3BucketName()).thenReturn("test-bucket");
+ when(this.s3Client.headBucket(any(HeadBucketRequest.class)))
+ .thenReturn(HeadBucketResponse.builder().build());
+ BlobStoreManager result = this.componentManager.getInstance(BlobStoreManager.class, "s3");
+ assertEquals("S3 blob store manager initialized for bucket: test-bucket",
+ this.logCapture.getMessage(0));
+ return result;
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreTest.java
new file mode 100644
index 0000000000..66345a7cbf
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobStoreTest.java
@@ -0,0 +1,581 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import software.amazon.awssdk.services.s3.S3Client;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3BlobStore}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class S3BlobStoreTest
+{
+ private static final String STORE_NAME = "test-store";
+
+ private static final String BUCKET_NAME = "test-bucket";
+
+ private static final String KEY_PREFIX = "prefix";
+
+ private static final BlobPath BLOB_PATH = BlobPath.of(List.of("dir", "file.txt"));
+
+ private static final BlobPath SOURCE_PATH = BlobPath.of(List.of("source", "file.txt"));
+
+ private static final BlobPath TARGET_PATH = BlobPath.of(List.of("target", "file.txt"));
+
+ @InjectMockComponents
+ private S3BlobStore store;
+
+ @MockComponent
+ private S3ClientManager clientManager;
+
+ @MockComponent
+ private S3CopyOperations copyOperations;
+
+ @MockComponent
+ private S3DeleteOperations deleteOperations;
+
+ private S3Client s3Client;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.s3Client = mock();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ this.store.initialize(STORE_NAME, BUCKET_NAME, KEY_PREFIX);
+ }
+
+ @Test
+ void initialize()
+ {
+ S3BlobStore newStore = new S3BlobStore();
+ newStore.initialize("my-store", "my-bucket", "my-prefix/");
+
+ assertEquals("my-store", newStore.getName());
+ assertEquals("my-bucket", newStore.getBucketName());
+ assertEquals("my-prefix", newStore.getKeyMapper().getKeyPrefix());
+ }
+
+ @Test
+ void initializeWithEmptyPrefix()
+ {
+ S3BlobStore newStore = new S3BlobStore();
+ newStore.initialize("store", "bucket", "");
+
+ assertEquals("store", newStore.getName());
+ assertEquals("bucket", newStore.getBucketName());
+ assertEquals("", newStore.getKeyMapper().getKeyPrefix());
+ }
+
+ @Test
+ void getBlobCreatesS3BlobWithCorrectArguments() throws BlobStoreException
+ {
+ BlobPath path = BlobPath.of(List.of("my", "test", "file.dat"));
+
+ try (MockedConstruction mockedBlob = mockConstruction(S3Blob.class, (mock, context) -> {
+ assertEquals(path, context.arguments().get(0));
+ assertEquals(BUCKET_NAME, context.arguments().get(1));
+ assertEquals(KEY_PREFIX + "/my/test/file.dat", context.arguments().get(2));
+ assertEquals(this.store, context.arguments().get(3));
+ assertEquals(this.s3Client, context.arguments().get(4));
+ })) {
+
+ Blob blob = this.store.getBlob(path);
+
+ assertThat(blob, instanceOf(S3Blob.class));
+ assertEquals(1, mockedBlob.constructed().size());
+ }
+ }
+
+ @Test
+ void getBlobWithRootPath() throws BlobStoreException
+ {
+ BlobPath rootPath = BlobPath.of(List.of("file.txt"));
+
+ try (MockedConstruction mockedBlob = mockConstruction(S3Blob.class, (mock, context) -> {
+ assertEquals(rootPath, context.arguments().get(0));
+ assertEquals(BUCKET_NAME, context.arguments().get(1));
+ assertEquals(KEY_PREFIX + "/file.txt", context.arguments().get(2));
+ })) {
+
+ Blob blob = this.store.getBlob(rootPath);
+
+ assertThat(blob, instanceOf(S3Blob.class));
+ assertEquals(1, mockedBlob.constructed().size());
+ }
+ }
+
+ @Test
+ void listBlobsReturnsEmptyStream()
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ })) {
+
+ Stream blobs = this.store.listBlobs(BLOB_PATH);
+ List blobList = blobs.toList();
+
+ assertEquals(0, blobList.size());
+ assertEquals(1, mockedIterator.constructed().size());
+ }
+ }
+
+ @Test
+ void listBlobsReturnsMultipleBlobs()
+ {
+ List expectedBlobs = List.of(mock(), mock(), mock());
+
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ // Mock forEachRemaining which is called by toList() for efficiency.
+ doAnswer(invocation -> {
+ Consumer action = invocation.getArgument(0);
+ expectedBlobs.forEach(action);
+ return null;
+ }).when(mock).forEachRemaining(any());
+ })) {
+
+ Stream blobs = this.store.listBlobs(BLOB_PATH);
+ List blobList = blobs.toList();
+
+ // Verify the iterator was actually used.
+ S3BlobIterator iterator = mockedIterator.constructed().get(0);
+ verify(iterator).forEachRemaining(any());
+ assertEquals(1, mockedIterator.constructed().size());
+
+ assertEquals(expectedBlobs, blobList);
+ }
+ }
+
+ @Test
+ void listBlobsUsesCorrectPrefix()
+ {
+ BlobPath path = BlobPath.of(List.of("folder", "subfolder"));
+
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ // Verify constructor arguments
+ assertEquals(KEY_PREFIX + "/folder/subfolder/", context.arguments().get(0));
+ assertEquals(BUCKET_NAME, context.arguments().get(1));
+ assertEquals(1000, context.arguments().get(2));
+ assertEquals(this.s3Client, context.arguments().get(3));
+ assertEquals(this.store, context.arguments().get(4));
+ })) {
+
+ try (Stream blobs = this.store.listBlobs(path)) {
+ assertEquals(0, blobs.count());
+ }
+
+ assertEquals(1, mockedIterator.constructed().size());
+ }
+ }
+
+ @Test
+ void listBlobsWithRootPath()
+ {
+ BlobPath rootPath = BlobPath.of(List.of());
+
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ assertEquals(KEY_PREFIX + "/", context.arguments().get(0));
+ })) {
+
+ try (Stream blobs = this.store.listBlobs(rootPath)) {
+ assertEquals(0, blobs.count());
+ }
+ }
+ }
+
+ @Test
+ void copyBlobInternalDelegates() throws BlobStoreException
+ {
+ Blob expectedBlob = mock();
+ when(this.copyOperations.copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenReturn(expectedBlob);
+
+ Blob result = this.store.copyBlob(SOURCE_PATH, TARGET_PATH);
+
+ assertEquals(expectedBlob, result);
+ verify(this.copyOperations).copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH);
+ }
+
+ @Test
+ void copyBlobCrossStoreDelegates() throws BlobStoreException
+ {
+ BlobStore sourceStore = mock();
+ Blob expectedBlob = mock();
+ when(this.copyOperations.copyBlob(sourceStore, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenReturn(expectedBlob);
+
+ Blob result = this.store.copyBlob(sourceStore, SOURCE_PATH, TARGET_PATH);
+
+ assertEquals(expectedBlob, result);
+ verify(this.copyOperations).copyBlob(sourceStore, SOURCE_PATH, this.store, TARGET_PATH);
+ }
+
+ @Test
+ void copyBlobPropagatesException() throws BlobStoreException
+ {
+ BlobStoreException exception = new BlobStoreException("Copy failed");
+ when(this.copyOperations.copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenThrow(exception);
+
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.store.copyBlob(SOURCE_PATH, TARGET_PATH));
+
+ assertEquals(exception, thrown);
+ assertEquals("Copy failed", thrown.getMessage());
+ }
+
+ @Test
+ void moveBlobInternalCopiesAndDeletes() throws BlobStoreException
+ {
+ Blob copiedBlob = mock();
+ when(this.copyOperations.copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenReturn(copiedBlob);
+
+ Blob result = this.store.moveBlob(SOURCE_PATH, TARGET_PATH);
+
+ assertEquals(copiedBlob, result);
+ verify(this.copyOperations).copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH);
+ verify(this.deleteOperations).deleteBlob(this.store, SOURCE_PATH);
+ }
+
+ @Test
+ void moveBlobCrossStoreCopiesAndDeletes() throws BlobStoreException
+ {
+ BlobStore sourceStore = mock();
+ Blob copiedBlob = mock();
+ when(this.copyOperations.copyBlob(sourceStore, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenReturn(copiedBlob);
+
+ Blob result = this.store.moveBlob(sourceStore, SOURCE_PATH, TARGET_PATH);
+
+ assertEquals(copiedBlob, result);
+ verify(this.copyOperations).copyBlob(sourceStore, SOURCE_PATH, this.store, TARGET_PATH);
+ verify(sourceStore).deleteBlob(SOURCE_PATH);
+ }
+
+ @Test
+ void moveBlobDoesNotDeleteOnCopyFailure() throws BlobStoreException
+ {
+ when(this.copyOperations.copyBlob(this.store, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenThrow(new BlobStoreException("Copy failed"));
+
+ assertThrows(BlobStoreException.class, () -> this.store.moveBlob(SOURCE_PATH, TARGET_PATH));
+
+ verify(this.deleteOperations, never()).deleteBlob(any(), any());
+ }
+
+ @Test
+ void moveBlobCrossStoreDoesNotDeleteOnCopyFailure() throws BlobStoreException
+ {
+ BlobStore sourceStore = mock();
+ when(this.copyOperations.copyBlob(sourceStore, SOURCE_PATH, this.store, TARGET_PATH))
+ .thenThrow(new BlobStoreException("Copy failed"));
+
+ assertThrows(BlobStoreException.class, () -> this.store.moveBlob(sourceStore, SOURCE_PATH, TARGET_PATH));
+
+ verify(sourceStore, never()).deleteBlob(any());
+ }
+
+ @Test
+ void isEmptyDirectoryWhenEmpty() throws BlobStoreException
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ // Verify page size is 1 for efficiency
+ assertEquals(1, context.arguments().get(2));
+ })) {
+
+ boolean result = this.store.isEmptyDirectory(BLOB_PATH);
+
+ assertTrue(result);
+ assertEquals(1, mockedIterator.constructed().size());
+ }
+ }
+
+ @Test
+ void isEmptyDirectoryWhenNotEmpty() throws BlobStoreException
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(true);
+ })) {
+
+ boolean result = this.store.isEmptyDirectory(BLOB_PATH);
+
+ assertFalse(result);
+ assertEquals(1, mockedIterator.constructed().size());
+ }
+ }
+
+ @Test
+ void isEmptyDirectoryWrapsRuntimeException()
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenThrow(new RuntimeException("S3 connection failed"));
+ })) {
+
+ BlobPath path = BlobPath.of(List.of("error", "path"));
+
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.store.isEmptyDirectory(path));
+
+ assertThat(thrown.getMessage(), containsString("Failed to check if directory is empty"));
+ assertThat(thrown.getMessage(), containsString("error/path"));
+ assertEquals(RuntimeException.class, thrown.getCause().getClass());
+ assertEquals("S3 connection failed", thrown.getCause().getMessage());
+ }
+ }
+
+ @Test
+ void isEmptyDirectoryWrapsIllegalStateException()
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenThrow(new IllegalStateException("Invalid state"));
+ })) {
+
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.store.isEmptyDirectory(BLOB_PATH));
+
+ assertThat(thrown.getMessage(), containsString("Failed to check if directory is empty"));
+ assertEquals(IllegalStateException.class, thrown.getCause().getClass());
+ }
+ }
+
+ @Test
+ void deleteBlobDelegates() throws BlobStoreException
+ {
+ this.store.deleteBlob(BLOB_PATH);
+
+ verify(this.deleteOperations).deleteBlob(this.store, BLOB_PATH);
+ }
+
+ @Test
+ void deleteBlobPropagatesException() throws BlobStoreException
+ {
+ BlobStoreException exception = new BlobStoreException("Delete failed");
+ doThrow(exception).when(this.deleteOperations).deleteBlob(this.store, BLOB_PATH);
+
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.store.deleteBlob(BLOB_PATH));
+
+ assertEquals(exception, thrown);
+ assertEquals("Delete failed", thrown.getMessage());
+ }
+
+ @Test
+ void deleteBlobsListsAndDeletes() throws BlobStoreException
+ {
+ Blob blob1 = mock();
+ Blob blob2 = mock();
+
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(true, true, false);
+ when(mock.next()).thenReturn(blob1, blob2);
+ })) {
+
+ this.store.deleteBlobs(BLOB_PATH);
+
+ verify(this.deleteOperations).deleteBlobs(eq(this.store), any(Stream.class));
+ }
+ }
+
+ @Test
+ void deleteBlobsClosesStream() throws BlobStoreException
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ })) {
+
+ this.store.deleteBlobs(BLOB_PATH);
+
+ // Verify that the stream is properly closed by using try-with-resources
+ // If the stream wasn't closed, this would potentially leak resources
+ verify(this.deleteOperations).deleteBlobs(eq(this.store), any(Stream.class));
+ }
+ }
+
+ @Test
+ void deleteBlobsWithEmptyDirectory() throws BlobStoreException
+ {
+ try (MockedConstruction mockedIterator = mockConstruction(S3BlobIterator.class,
+ (mock, context) -> {
+ when(mock.hasNext()).thenReturn(false);
+ })) {
+
+ this.store.deleteBlobs(BLOB_PATH);
+
+ verify(this.deleteOperations).deleteBlobs(eq(this.store), any(Stream.class));
+ }
+ }
+
+ @Test
+ void equalsWithSameInstance()
+ {
+ assertEquals(this.store, this.store);
+ }
+
+ @Test
+ void equalsWithEqualStore()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize(STORE_NAME, BUCKET_NAME, KEY_PREFIX);
+
+ assertEquals(this.store, other);
+ assertEquals(this.store.hashCode(), other.hashCode());
+ }
+
+ @Test
+ void equalsWithDifferentBucket()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize(STORE_NAME, "different-bucket", KEY_PREFIX);
+
+ assertNotEquals(this.store, other);
+ }
+
+ @Test
+ void equalsWithDifferentPrefix()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize(STORE_NAME, BUCKET_NAME, "different-prefix/");
+
+ assertNotEquals(this.store, other);
+ }
+
+ @Test
+ void equalsWithDifferentName()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize("different-name", BUCKET_NAME, KEY_PREFIX);
+
+ // Name is not part of equals comparison, only bucket and keyMapper
+ assertEquals(this.store, other);
+ }
+
+ @Test
+ void equalsWithNull()
+ {
+ assertNotEquals(null, this.store);
+ }
+
+ @Test
+ void equalsWithDifferentClass()
+ {
+ assertNotEquals("not a store", this.store);
+ }
+
+ @Test
+ void hashCodeConsistency()
+ {
+ int hash1 = this.store.hashCode();
+ int hash2 = this.store.hashCode();
+
+ assertEquals(hash1, hash2);
+ }
+
+ @Test
+ void hashCodeWithEqualStores()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize(STORE_NAME, BUCKET_NAME, KEY_PREFIX);
+
+ assertEquals(this.store.hashCode(), other.hashCode());
+ }
+
+ @Test
+ void hashCodeWithDifferentStores()
+ {
+ S3BlobStore other = new S3BlobStore();
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+ other.initialize(STORE_NAME, "different-bucket", KEY_PREFIX);
+
+ assertNotEquals(this.store.hashCode(), other.hashCode());
+ }
+
+ @Test
+ void getBucketNameReturnsCorrectValue()
+ {
+ assertEquals(BUCKET_NAME, this.store.getBucketName());
+ }
+
+ @Test
+ void getKeyMapperReturnsCorrectValue()
+ {
+ S3KeyMapper mapper = this.store.getKeyMapper();
+
+ assertEquals(KEY_PREFIX, mapper.getKeyPrefix());
+ }
+
+ @Test
+ void getNameReturnsCorrectValue()
+ {
+ assertEquals(STORE_NAME, this.store.getName());
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobTest.java
new file mode 100644
index 0000000000..fa9cb43b71
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3BlobTest.java
@@ -0,0 +1,195 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for S3Blob.
+ *
+ * @version $Id$
+ */
+@ExtendWith(MockitoExtension.class)
+class S3BlobTest
+{
+ private static final String BUCKET = "bucket";
+
+ private static final String KEY = "key";
+
+ private static final BlobPath BLOB_PATH = BlobPath.of(List.of("my", "blob.txt"));
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private S3BlobStore store;
+
+ private S3Blob blob;
+
+ @BeforeEach
+ void setUp()
+ {
+ this.blob = new S3Blob(BLOB_PATH, BUCKET, KEY, this.store, this.s3Client);
+ }
+
+ @Test
+ void existsWhenObjectExists() throws BlobStoreException
+ {
+ HeadObjectResponse response = HeadObjectResponse.builder().build();
+ when(this.s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(response);
+
+ boolean exists = this.blob.exists();
+
+ assertTrue(exists);
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).headObject(captor.capture());
+ HeadObjectRequest request = captor.getValue();
+ assertEquals(BUCKET, request.bucket());
+ assertEquals(KEY, request.key());
+ }
+
+ @Test
+ void existsWhenObjectMissing() throws BlobStoreException
+ {
+ when(this.s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenThrow(NoSuchKeyException.builder().message("missing").build());
+
+ boolean exists = this.blob.exists();
+
+ assertFalse(exists);
+ }
+
+ @Test
+ void existsWhenS3Exception()
+ {
+ when(this.s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenThrow(S3Exception.builder().message("error").statusCode(500).build());
+
+ assertThrows(BlobStoreException.class, () -> this.blob.exists());
+ }
+
+ @Test
+ void getSizeWhenPresent() throws BlobStoreException
+ {
+ HeadObjectResponse response = HeadObjectResponse.builder().contentLength(42L).build();
+ when(this.s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(response);
+
+ long size = this.blob.getSize();
+
+ assertEquals(42L, size);
+ }
+
+ @Test
+ void getSizeWhenMissing() throws BlobStoreException
+ {
+ when(this.s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenThrow(NoSuchKeyException.builder().message("missing").build());
+
+ long size = this.blob.getSize();
+
+ assertEquals(-1L, size);
+ }
+
+ @Test
+ void getSizeWhenS3Exception()
+ {
+ when(this.s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenThrow(S3Exception.builder().message("error").statusCode(500).build());
+
+ assertThrows(BlobStoreException.class, () -> this.blob.getSize());
+ }
+
+ @Test
+ void getOutputStreamReturnsS3BlobOutputStream() throws BlobStoreException
+ {
+ OutputStream outputStream = this.blob.getOutputStream(BlobDoesNotExistCondition.INSTANCE);
+
+ assertThat(outputStream, instanceOf(S3BlobOutputStream.class));
+ }
+
+ @Test
+ void getStreamWhenPresent() throws BlobStoreException
+ {
+ ResponseInputStream responseStream = mock();
+ when(this.s3Client.getObject(any(GetObjectRequest.class))).thenReturn(responseStream);
+
+ InputStream actual = this.blob.getStream();
+
+ assertSame(responseStream, actual);
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).getObject(captor.capture());
+ GetObjectRequest request = captor.getValue();
+ assertEquals(BUCKET, request.bucket());
+ assertEquals(KEY, request.key());
+ }
+
+ @Test
+ void getStreamWhenMissing()
+ {
+ when(this.s3Client.getObject(any(GetObjectRequest.class)))
+ .thenThrow(NoSuchKeyException.builder().message("missing").build());
+
+ assertThrows(BlobNotFoundException.class, () -> this.blob.getStream());
+ }
+
+ @Test
+ void getStreamWhenS3Exception()
+ {
+ AwsServiceException exception = S3Exception.builder().message("error").statusCode(500).build();
+ when(this.s3Client.getObject(any(GetObjectRequest.class))).thenThrow(exception);
+
+ assertThrows(BlobStoreException.class, () -> this.blob.getStream());
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3ClientManagerTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3ClientManagerTest.java
new file mode 100644
index 0000000000..e8bd42b67a
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3ClientManagerTest.java
@@ -0,0 +1,225 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.net.URI;
+import java.net.URL;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.annotation.ComponentList;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectComponentManager;
+import org.xwiki.test.junit5.mockito.MockComponent;
+import org.xwiki.test.mockito.MockitoComponentManager;
+
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.retries.api.RetryStrategy;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetUrlRequest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3ClientManager}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+@ComponentList(S3ClientManager.class)
+class S3ClientManagerTest
+{
+ @InjectComponentManager
+ private MockitoComponentManager componentManager;
+
+ @MockComponent
+ private S3BlobStoreConfiguration configuration;
+
+ @RegisterExtension
+ private final LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.INFO);
+
+ /**
+ * Helper method to configure the mock with standard test values.
+ */
+ private void configureFullConfiguration()
+ {
+ when(this.configuration.getS3Region()).thenReturn("us-west-2");
+ when(this.configuration.getS3AccessKey()).thenReturn("accesskey");
+ when(this.configuration.getS3SecretKey()).thenReturn("secretkey");
+ when(this.configuration.getS3Endpoint()).thenReturn("https://s3.amazonaws.com");
+ when(this.configuration.isS3PathStyleAccess()).thenReturn(true);
+ when(this.configuration.getS3MaxConnections()).thenReturn(50);
+ when(this.configuration.getS3ConnectionTimeout()).thenReturn(5000);
+ when(this.configuration.getS3SocketTimeout()).thenReturn(10000);
+ when(this.configuration.getS3RequestTimeout()).thenReturn(15000);
+ when(this.configuration.getS3MaxRetries()).thenReturn(3);
+ }
+
+ /**
+ * Helper method to configure the mock with minimal values.
+ * Uses us-east-1 as a valid default region to avoid SDK auto-detection failures.
+ */
+ private void configureMinimalConfiguration()
+ {
+ when(this.configuration.getS3Region()).thenReturn("us-east-1");
+ when(this.configuration.getS3AccessKey()).thenReturn("");
+ when(this.configuration.getS3SecretKey()).thenReturn("");
+ when(this.configuration.getS3Endpoint()).thenReturn("");
+ when(this.configuration.isS3PathStyleAccess()).thenReturn(false);
+ when(this.configuration.getS3MaxConnections()).thenReturn(10);
+ when(this.configuration.getS3ConnectionTimeout()).thenReturn(3000);
+ when(this.configuration.getS3SocketTimeout()).thenReturn(5000);
+ when(this.configuration.getS3RequestTimeout()).thenReturn(10000);
+ when(this.configuration.getS3MaxRetries()).thenReturn(2);
+ }
+
+ /**
+ * Helper method to verify path-style access by examining generated URLs.
+ * Path-style: https://endpoint/bucket/key
+ * Virtual-hosted-style: https://bucket.endpoint/key
+ */
+ private void assertPathStyleAccess(S3Client s3Client, boolean expectedPathStyle)
+ {
+ GetUrlRequest request = GetUrlRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .build();
+
+ URL url = s3Client.utilities().getUrl(request);
+ String urlString = url.toString();
+
+ if (expectedPathStyle) {
+ // Path-style: bucket name should be in the path
+ assertTrue(urlString.contains("/test-bucket/test-key"),
+ "Expected path-style URL containing '/test-bucket/test-key', got: " + urlString);
+ } else {
+ // Virtual-hosted-style: bucket name should be in the hostname
+ assertTrue(url.getHost().startsWith("test-bucket."),
+ "Expected virtual-hosted-style URL with 'test-bucket.' in hostname, got: " + urlString);
+ }
+ }
+
+ @Test
+ void initializeWithFullConfigurationAndVerifyClientConfiguration() throws Exception
+ {
+ configureFullConfiguration();
+
+ S3ClientManager s3ClientManager = this.componentManager.getInstance(S3ClientManager.class);
+ S3Client s3Client = s3ClientManager.getS3Client();
+
+ // Verify the client is created and cached
+ assertNotNull(s3Client);
+ assertSame(s3Client, s3ClientManager.getS3Client());
+
+ // Verify actual client configuration
+ var clientConfig = s3Client.serviceClientConfiguration();
+
+ // Verify region
+ assertEquals(Region.US_WEST_2, clientConfig.region());
+
+ // Verify endpoint override
+ assertTrue(clientConfig.endpointOverride().isPresent());
+ assertEquals(URI.create("https://s3.amazonaws.com"), clientConfig.endpointOverride().get());
+
+ // Verify path style access through URL generation
+ assertPathStyleAccess(s3Client, true);
+
+ // Verify retry strategy
+ assertTrue(clientConfig.overrideConfiguration().retryStrategy().isPresent());
+ RetryStrategy retryStrategy = clientConfig.overrideConfiguration().retryStrategy().get();
+ assertEquals(3, retryStrategy.maxAttempts());
+
+ // Verify request timeout
+ assertTrue(clientConfig.overrideConfiguration().apiCallTimeout().isPresent());
+ assertEquals(15000, clientConfig.overrideConfiguration().apiCallTimeout().get().toMillis());
+
+ assertEquals("S3 client initialized successfully", this.logCapture.getMessage(0));
+
+ s3ClientManager.dispose();
+ assertEquals("S3 client disposed", this.logCapture.getMessage(1));
+ }
+
+ @Test
+ void initializeWithMinimalConfigurationAndVerifyDefaults() throws Exception
+ {
+ configureMinimalConfiguration();
+
+ S3ClientManager s3ClientManager = this.componentManager.getInstance(S3ClientManager.class);
+ S3Client s3Client = s3ClientManager.getS3Client();
+
+ assertNotNull(s3Client);
+
+ var clientConfig = s3Client.serviceClientConfiguration();
+
+ // Verify region is set to us-east-1
+ assertEquals(Region.US_EAST_1, clientConfig.region());
+
+ // Verify no endpoint override
+ assertTrue(clientConfig.endpointOverride().isEmpty());
+
+ // Verify path style access is disabled (virtual-hosted-style)
+ assertPathStyleAccess(s3Client, false);
+
+ // Verify retry strategy with minimal config
+ assertTrue(clientConfig.overrideConfiguration().retryStrategy().isPresent());
+ RetryStrategy retryStrategy = clientConfig.overrideConfiguration().retryStrategy().get();
+ assertEquals(2, retryStrategy.maxAttempts());
+
+ // Verify request timeout
+ assertTrue(clientConfig.overrideConfiguration().apiCallTimeout().isPresent());
+ assertEquals(10000, clientConfig.overrideConfiguration().apiCallTimeout().get().toMillis());
+
+ assertEquals("Using default AWS credentials provider chain", this.logCapture.getMessage(0));
+ assertEquals("S3 client initialized successfully", this.logCapture.getMessage(1));
+
+ s3ClientManager.dispose();
+ assertEquals("S3 client disposed", this.logCapture.getMessage(2));
+ }
+
+ @Test
+ void clientIsCachedAndReused() throws Exception
+ {
+ configureMinimalConfiguration();
+
+ S3ClientManager s3ClientManager = this.componentManager.getInstance(S3ClientManager.class);
+
+ S3Client firstCall = s3ClientManager.getS3Client();
+ S3Client secondCall = s3ClientManager.getS3Client();
+ S3Client thirdCall = s3ClientManager.getS3Client();
+
+ assertNotNull(firstCall);
+ assertSame(firstCall, secondCall);
+ assertSame(firstCall, thirdCall);
+
+ // Only one initialization log message
+ assertEquals(2, this.logCapture.size());
+ assertEquals("Using default AWS credentials provider chain", this.logCapture.getMessage(0));
+ assertEquals("S3 client initialized successfully", this.logCapture.getMessage(1));
+
+ s3ClientManager.dispose();
+ assertEquals("S3 client disposed", this.logCapture.getMessage(2));
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3CopyOperationsTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3CopyOperationsTest.java
new file mode 100644
index 0000000000..4ae6cc55f6
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3CopyOperationsTest.java
@@ -0,0 +1,402 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobAlreadyExistsException;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobNotFoundException;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStore;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
+import software.amazon.awssdk.services.s3.model.CopyPartResult;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartCopyResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3CopyOperations}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class S3CopyOperationsTest
+{
+ @InjectMockComponents
+ private S3CopyOperations copyOperations;
+
+ @MockComponent
+ private S3ClientManager clientManager;
+
+ @MockComponent
+ private S3BlobStoreConfiguration configuration;
+
+ @RegisterExtension
+ private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.DEBUG);
+
+ @Mock
+ private S3BlobStore sourceStore;
+
+ @Mock
+ private S3BlobStore targetStore;
+
+ @Mock
+ private BlobPath sourcePath;
+
+ @Mock
+ private BlobPath targetPath;
+
+ @Mock
+ private S3KeyMapper keyMapper;
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private Blob sourceBlob;
+
+ @Mock
+ private Blob targetBlob;
+
+ @BeforeEach
+ void setUp() throws BlobStoreException
+ {
+ when(this.sourceStore.getKeyMapper()).thenReturn(this.keyMapper);
+ when(this.targetStore.getKeyMapper()).thenReturn(this.keyMapper);
+ when(this.keyMapper.buildS3Key(this.sourcePath)).thenReturn("source-key");
+ when(this.keyMapper.buildS3Key(this.targetPath)).thenReturn("target-key");
+ when(this.sourceStore.getBucketName()).thenReturn("source-bucket");
+ when(this.targetStore.getBucketName()).thenReturn("target-bucket");
+ when(this.sourceStore.getBlob(this.sourcePath)).thenReturn(this.sourceBlob);
+ when(this.targetStore.getBlob(this.targetPath)).thenReturn(this.targetBlob);
+ when(this.clientManager.getS3Client()).thenReturn(this.s3Client);
+
+ // Default part size: 5MB (5 * 1024 * 1024 bytes)
+ when(this.configuration.getS3MultipartPartUploadSizeBytes()).thenReturn(5 * 1024L * 1024L);
+ // Default copy size: 512MB (512 * 1024 * 1024 bytes)
+ when(this.configuration.getS3MultipartCopySizeBytes()).thenReturn(512 * 1024L * 1024L);
+ }
+
+ @Test
+ void copyBlobThrowsExceptionWhenSourceAndTargetAreSame()
+ {
+ BlobStore store = mock();
+ BlobPath path = mock();
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.copyOperations.copyBlob(store, path, store, path));
+
+ assertEquals("Source and target blob are the same: " + path, exception.getMessage());
+ }
+
+ @Test
+ void copyBlobWithStreamWhenStoresAreNotS3() throws Exception
+ {
+ BlobStore nonS3SourceStore = mock();
+ BlobStore nonS3TargetStore = mock();
+ BlobPath nonS3SourcePath = mock();
+ BlobPath nonS3TargetPath = mock();
+
+ Blob nonS3SourceBlob = mock();
+ Blob nonS3TargetBlob = mock();
+ InputStream inputStream = new ByteArrayInputStream("test data".getBytes());
+
+ when(nonS3SourceStore.getBlob(nonS3SourcePath)).thenReturn(nonS3SourceBlob);
+ when(nonS3SourceBlob.getStream()).thenReturn(inputStream);
+ when(nonS3TargetStore.getBlob(nonS3TargetPath)).thenReturn(nonS3TargetBlob);
+ when(nonS3TargetBlob.exists()).thenReturn(false);
+
+ Blob result = this.copyOperations.copyBlob(nonS3SourceStore, nonS3SourcePath, nonS3TargetStore,
+ nonS3TargetPath);
+
+ assertNotNull(result);
+ assertEquals(nonS3TargetBlob, result);
+ verify(nonS3TargetBlob).writeFromStream(any(InputStream.class), any(BlobDoesNotExistCondition.class));
+ }
+
+ @Test
+ void copyBlobWithStreamThrowsExceptionWhenTargetExists() throws Exception
+ {
+ BlobStore nonS3SourceStore = mock();
+ BlobStore nonS3TargetStore = mock();
+ BlobPath nonS3SourcePath = mock();
+ BlobPath nonS3TargetPath = mock();
+
+ Blob nonS3SourceBlob = mock();
+ Blob nonS3TargetBlob = mock();
+ InputStream inputStream = new ByteArrayInputStream("test data".getBytes());
+
+ when(nonS3SourceStore.getBlob(nonS3SourcePath)).thenReturn(nonS3SourceBlob);
+ when(nonS3SourceBlob.getStream()).thenReturn(inputStream);
+ when(nonS3TargetStore.getBlob(nonS3TargetPath)).thenReturn(nonS3TargetBlob);
+ when(nonS3TargetBlob.exists()).thenReturn(true);
+
+ assertThrows(BlobAlreadyExistsException.class,
+ () -> this.copyOperations.copyBlob(nonS3SourceStore, nonS3SourcePath, nonS3TargetStore,
+ nonS3TargetPath));
+
+ verify(nonS3TargetBlob, never()).writeFromStream(any(), any());
+ }
+
+ @Test
+ void copyBlobWithStreamWrapsNonBlobStoreExceptions() throws Exception
+ {
+ BlobStore nonS3SourceStore = mock();
+ BlobStore nonS3TargetStore = mock();
+ BlobPath nonS3SourcePath = mock();
+ BlobPath nonS3TargetPath = mock();
+
+ Blob nonS3SourceBlob = mock();
+
+ when(nonS3SourceStore.getBlob(nonS3SourcePath)).thenReturn(nonS3SourceBlob);
+ when(nonS3SourceBlob.getStream()).thenThrow(new RuntimeException("IO error"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.copyOperations.copyBlob(nonS3SourceStore, nonS3SourcePath, nonS3TargetStore,
+ nonS3TargetPath));
+
+ assertEquals("Failed to copy blob from external store", exception.getMessage());
+ }
+
+ @Test
+ void copyBlobS3StoreSimpleCopyForSmallObject() throws Exception
+ {
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(1024L);
+ when(this.s3Client.copyObject(any(CopyObjectRequest.class))).thenReturn(mock());
+
+ Blob result = this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore,
+ this.targetPath);
+
+ assertNotNull(result);
+ ArgumentCaptor requestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).copyObject(requestCaptor.capture());
+
+ CopyObjectRequest request = requestCaptor.getValue();
+ assertEquals("source-bucket", request.sourceBucket());
+ assertEquals("source-key", request.sourceKey());
+ assertEquals("target-bucket", request.destinationBucket());
+ assertEquals("target-key", request.destinationKey());
+ assertEquals("COPY", request.metadataDirective().toString());
+ }
+
+ @Test
+ void copyBlobS3StoreThrowsExceptionWhenTargetExists() throws Exception
+ {
+ when(this.targetBlob.exists()).thenReturn(true);
+
+ assertThrows(BlobAlreadyExistsException.class,
+ () -> this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore, this.targetPath));
+ }
+
+ @Test
+ void copyBlobS3StoreThrowsExceptionWhenSourceNotFound() throws Exception
+ {
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(-1L);
+
+ assertThrows(BlobNotFoundException.class,
+ () -> this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore, this.targetPath));
+ }
+
+ @Test
+ void copyBlobS3StoreMultipartCopyForLargeObject() throws Exception
+ {
+ // Use 600 MiB so that with a 512 MiB copy part size we get 2 parts
+ long largeObjectSize = 600L * 1024 * 1024;
+
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(largeObjectSize);
+
+ // Mock HeadObjectResponse with metadata
+ HeadObjectResponse headResponse = mock();
+ Map metadata = Map.of("content-type", "application/octet-stream", "custom-key", "custom-value");
+ when(headResponse.metadata()).thenReturn(metadata);
+ when(this.s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(headResponse);
+
+ CreateMultipartUploadResponse createResponse = mock();
+ when(createResponse.uploadId()).thenReturn("test-upload-id");
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ UploadPartCopyResponse part1Response = mock();
+ CopyPartResult part1Result = mock();
+ when(part1Result.eTag()).thenReturn("etag1");
+ when(part1Response.copyPartResult()).thenReturn(part1Result);
+
+ UploadPartCopyResponse part2Response = mock();
+ CopyPartResult part2Result = mock();
+ when(part2Result.eTag()).thenReturn("etag2");
+ when(part2Response.copyPartResult()).thenReturn(part2Result);
+
+ when(this.s3Client.uploadPartCopy(any(UploadPartCopyRequest.class)))
+ .thenReturn(part1Response, part2Response);
+
+ when(this.s3Client.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
+ .thenReturn(mock());
+
+ Blob result = this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore,
+ this.targetPath);
+
+ assertNotNull(result);
+
+ // Verify HeadObject was called to retrieve metadata
+ ArgumentCaptor headRequestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).headObject(headRequestCaptor.capture());
+ HeadObjectRequest headRequest = headRequestCaptor.getValue();
+ assertEquals("source-bucket", headRequest.bucket());
+ assertEquals("source-key", headRequest.key());
+
+ // Verify CreateMultipartUpload was called with metadata
+ ArgumentCaptor createRequestCaptor = ArgumentCaptor.captor();
+ verify(this.s3Client).createMultipartUpload(createRequestCaptor.capture());
+ CreateMultipartUploadRequest createRequest = createRequestCaptor.getValue();
+ assertEquals(metadata, createRequest.metadata());
+
+ verify(this.s3Client, times(2)).uploadPartCopy(any(UploadPartCopyRequest.class));
+ verify(this.s3Client).completeMultipartUpload(any(CompleteMultipartUploadRequest.class));
+
+ // Log messages from S3MultipartUploadHelper constructor
+ assertEquals("Initialized multipart upload for key target-key with upload ID: test-upload-id",
+ this.logCapture.getMessage(0));
+
+ // Log message from S3CopyOperations
+ assertEquals("Initiated multipart copy with upload ID: test-upload-id", this.logCapture.getMessage(1));
+
+ // Log messages from S3MultipartUploadHelper.addCompletedPart
+ assertEquals("Added completed part 1 for upload ID: test-upload-id", this.logCapture.getMessage(2));
+
+ // Log message from S3CopyOperations
+ assertEquals("Copied part 1 (bytes 0-536870911)", this.logCapture.getMessage(3));
+
+ // Log messages from S3MultipartUploadHelper.addCompletedPart
+ assertEquals("Added completed part 2 for upload ID: test-upload-id", this.logCapture.getMessage(4));
+
+ // Log message from S3CopyOperations
+ assertEquals("Copied part 2 (bytes 536870912-629145599)", this.logCapture.getMessage(5));
+
+ // Log messages from S3MultipartUploadHelper.complete
+ assertEquals("Completed multipart upload for key target-key with upload ID: test-upload-id",
+ this.logCapture.getMessage(6));
+
+ // Log message from S3CopyOperations
+ assertEquals("Completed multipart copy for key: target-key", this.logCapture.getMessage(7));
+ }
+
+ @Test
+ void copyBlobS3StoreMultipartCopyAbortsOnFailure() throws Exception
+ {
+ // Use 600 MiB so that with a 512 MiB copy part size we get 2 parts
+ long largeObjectSize = 600L * 1024 * 1024;
+
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(largeObjectSize);
+
+ // Mock HeadObjectResponse with metadata
+ HeadObjectResponse headResponse = mock();
+ when(headResponse.metadata()).thenReturn(Map.of());
+ when(this.s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(headResponse);
+
+ CreateMultipartUploadResponse createResponse = mock();
+ when(createResponse.uploadId()).thenReturn("test-upload-id");
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+
+ when(this.s3Client.uploadPartCopy(any(UploadPartCopyRequest.class)))
+ .thenThrow(new RuntimeException("Upload failed"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore, this.targetPath));
+
+ assertEquals("Failed to perform multipart copy", exception.getMessage());
+
+ // Log messages from S3MultipartUploadHelper constructor
+ assertEquals("Initialized multipart upload for key target-key with upload ID: test-upload-id",
+ this.logCapture.getMessage(0));
+
+ // Log message from S3CopyOperations
+ assertEquals("Initiated multipart copy with upload ID: test-upload-id", this.logCapture.getMessage(1));
+
+ // Log message from S3MultipartUploadHelper.abort
+ assertEquals("Aborted multipart upload for key target-key with upload ID: test-upload-id",
+ this.logCapture.getMessage(2));
+ }
+
+ @Test
+ void copyBlobS3StoreSimpleCopyThrowsExceptionOnFailure() throws Exception
+ {
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(1024L);
+ when(this.s3Client.copyObject(any(CopyObjectRequest.class)))
+ .thenThrow(new RuntimeException("Copy failed"));
+
+ BlobStoreException exception = assertThrows(BlobStoreException.class,
+ () -> this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore, this.targetPath));
+
+ assertEquals("Failed to perform simple copy", exception.getMessage());
+ }
+
+ @Test
+ void copyBlobS3StoreAtExactThreshold() throws Exception
+ {
+ long exactThreshold = 512L * 1024 * 1024;
+
+ when(this.targetBlob.exists()).thenReturn(false);
+ when(this.sourceBlob.getSize()).thenReturn(exactThreshold);
+ when(this.s3Client.copyObject(any(CopyObjectRequest.class))).thenReturn(mock());
+
+ this.copyOperations.copyBlob(this.sourceStore, this.sourcePath, this.targetStore, this.targetPath);
+
+ // Should use simple copy at threshold.
+ verify(this.s3Client).copyObject(any(CopyObjectRequest.class));
+ verify(this.s3Client, never()).uploadPartCopy(any(UploadPartCopyRequest.class));
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3DeleteOperationsTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3DeleteOperationsTest.java
new file mode 100644
index 0000000000..5ba0d2077e
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3DeleteOperationsTest.java
@@ -0,0 +1,325 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.xwiki.store.blob.Blob;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.BlobStoreException;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectResponse;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
+import software.amazon.awssdk.services.s3.model.S3Error;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3DeleteOperations}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class S3DeleteOperationsTest
+{
+ private static final String TEST_BUCKET = "test-bucket";
+
+ private static final String TEST_KEY = "test-key";
+
+ private static final BlobPath TEST_BLOB_PATH = BlobPath.of(List.of("test-path.txt"));
+
+ @InjectMockComponents
+ private S3DeleteOperations deleteOperations;
+
+ @Mock
+ private S3Client s3Client;
+
+ @MockComponent
+ private S3ClientManager s3ClientManager;
+
+ @Mock
+ private S3BlobStore blobStore;
+
+ @Mock
+ private S3KeyMapper keyMapper;
+
+ @BeforeEach
+ void setup()
+ {
+ when(this.s3ClientManager.getS3Client()).thenReturn(this.s3Client);
+ when(this.blobStore.getBucketName()).thenReturn(TEST_BUCKET);
+ when(this.blobStore.getKeyMapper()).thenReturn(this.keyMapper);
+ when(this.keyMapper.buildS3Key(TEST_BLOB_PATH)).thenReturn(TEST_KEY);
+ }
+
+ @Test
+ void deleteBlobSuccessfully() throws BlobStoreException
+ {
+ DeleteObjectResponse response = mock();
+ when(this.s3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(response);
+
+ this.deleteOperations.deleteBlob(this.blobStore, TEST_BLOB_PATH);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class);
+ verify(this.s3Client).deleteObject(captor.capture());
+ DeleteObjectRequest request = captor.getValue();
+ assertEquals(TEST_BUCKET, request.bucket());
+ assertEquals(TEST_KEY, request.key());
+ }
+
+ @Test
+ void deleteBlobWithS3Exception()
+ {
+ AwsServiceException exception = S3Exception.builder()
+ .message("Access Denied")
+ .statusCode(403)
+ .build();
+ when(this.s3Client.deleteObject(any(DeleteObjectRequest.class))).thenThrow(exception);
+
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.deleteOperations.deleteBlob(this.blobStore, TEST_BLOB_PATH));
+
+ assertTrue(thrown.getMessage().contains("Failed to delete blob"));
+ assertTrue(thrown.getMessage().contains(TEST_BLOB_PATH.toString()));
+ assertEquals(exception, thrown.getCause());
+ }
+
+ @Test
+ void deleteBlobsWithEmptyStream() throws BlobStoreException
+ {
+ Stream emptyStream = Stream.empty();
+
+ this.deleteOperations.deleteBlobs(this.blobStore, emptyStream);
+
+ // Verify no S3 calls were made
+ verify(this.s3Client, times(0)).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
+ @Test
+ void deleteBlobsWithSingleBlob() throws BlobStoreException
+ {
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("single.txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("single-key");
+
+ DeleteObjectsResponse response = mock();
+ when(response.errors()).thenReturn(Collections.emptyList());
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(response);
+
+ this.deleteOperations.deleteBlobs(this.blobStore, Stream.of(blob));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectsRequest.class);
+ verify(this.s3Client).deleteObjects(captor.capture());
+
+ DeleteObjectsRequest request = captor.getValue();
+ assertEquals(TEST_BUCKET, request.bucket());
+ assertEquals(1, request.delete().objects().size());
+ assertEquals("single-key", request.delete().objects().get(0).key());
+ }
+
+ @Test
+ void deleteBlobsWithMultipleBlobs() throws BlobStoreException
+ {
+ List blobs = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("file" + i + ".txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("key" + i);
+ blobs.add(blob);
+ }
+
+ DeleteObjectsResponse response = mock();
+ when(response.errors()).thenReturn(Collections.emptyList());
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(response);
+
+ this.deleteOperations.deleteBlobs(this.blobStore, blobs.stream());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectsRequest.class);
+ verify(this.s3Client).deleteObjects(captor.capture());
+
+ DeleteObjectsRequest request = captor.getValue();
+ assertEquals(TEST_BUCKET, request.bucket());
+ assertEquals(5, request.delete().objects().size());
+ }
+
+ @Test
+ void deleteBlobsWithExactly1000Blobs() throws BlobStoreException
+ {
+ // Setup - exactly at batch limit
+ List blobs = new ArrayList<>();
+ for (int i = 0; i < 1000; i++) {
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("file" + i + ".txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("key" + i);
+ blobs.add(blob);
+ }
+
+ DeleteObjectsResponse response = mock();
+ when(response.errors()).thenReturn(Collections.emptyList());
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(response);
+
+ this.deleteOperations.deleteBlobs(this.blobStore, blobs.stream());
+
+ verify(this.s3Client, times(1)).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
+ @Test
+ void deleteBlobsWithMoreThan1000Blobs() throws BlobStoreException
+ {
+ // Setup - more than batch limit, should trigger multiple batches
+ List blobs = new ArrayList<>();
+ for (int i = 0; i < 2500; i++) {
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("file" + i + ".txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("key" + i);
+ blobs.add(blob);
+ }
+
+ DeleteObjectsResponse response = mock();
+ when(response.errors()).thenReturn(Collections.emptyList());
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(response);
+
+ this.deleteOperations.deleteBlobs(this.blobStore, blobs.stream());
+
+ // Verify - should be 3 batches (1000 + 1000 + 500)
+ verify(this.s3Client, times(3)).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
+ @Test
+ void deleteBlobsWithMoreThan1000BlobsAndSomeErrorsInFirstPart()
+ {
+ // Setup - more than batch limit, should trigger multiple batches
+ List blobs = new ArrayList<>();
+ for (int i = 0; i < 1500; i++) {
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("file" + i + ".txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("key" + i);
+ blobs.add(blob);
+ }
+
+ S3Error error = S3Error.builder()
+ .key("key500")
+ .code("AccessDenied")
+ .message("Access Denied")
+ .build();
+
+ DeleteObjectsResponse firstResponse = DeleteObjectsResponse.builder()
+ .errors(Collections.singletonList(error))
+ .build();
+ DeleteObjectsResponse secondResponse = DeleteObjectsResponse.builder()
+ .errors(Collections.emptyList())
+ .build();
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class)))
+ .thenReturn(firstResponse)
+ .thenReturn(secondResponse);
+
+ // Execute & Verify
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.deleteOperations.deleteBlobs(this.blobStore, blobs.stream()));
+
+ assertThat(thrown.getMessage(), containsString("Failed to delete some blobs"));
+
+ // Verify - should be 2 batches (1000 + 500)
+ verify(this.s3Client, times(2)).deleteObjects(any(DeleteObjectsRequest.class));
+ }
+
+ @Test
+ void deleteBlobsWithPartialErrors()
+ {
+ // Setup
+ Blob blob1 = mock();
+ Blob blob2 = mock();
+ BlobPath path1 = BlobPath.of(List.of("file1.txt"));
+ BlobPath path2 = BlobPath.of(List.of("file2.txt"));
+ when(blob1.getPath()).thenReturn(path1);
+ when(blob2.getPath()).thenReturn(path2);
+ when(this.keyMapper.buildS3Key(path1)).thenReturn("key1");
+ when(this.keyMapper.buildS3Key(path2)).thenReturn("key2");
+
+ S3Error error = S3Error.builder()
+ .key("key2")
+ .code("AccessDenied")
+ .message("Access Denied")
+ .build();
+
+ DeleteObjectsResponse response = DeleteObjectsResponse.builder()
+ .errors(Collections.singletonList(error))
+ .build();
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(response);
+
+ // Execute & Verify
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.deleteOperations.deleteBlobs(this.blobStore, Stream.of(blob1, blob2)));
+
+ assertThat(thrown.getMessage(), containsString("Failed to delete some blobs"));
+ }
+
+ @Test
+ void deleteBlobsWithS3Exception()
+ {
+ // Setup
+ Blob blob = mock();
+ BlobPath path = BlobPath.of(List.of("file.txt"));
+ when(blob.getPath()).thenReturn(path);
+ when(this.keyMapper.buildS3Key(path)).thenReturn("key");
+
+ AwsServiceException exception = S3Exception.builder()
+ .message("Service Unavailable")
+ .statusCode(503)
+ .build();
+ when(this.s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenThrow(exception);
+
+ // Execute & Verify
+ BlobStoreException thrown = assertThrows(BlobStoreException.class,
+ () -> this.deleteOperations.deleteBlobs(this.blobStore, Stream.of(blob)));
+
+ assertThat(thrown.getMessage(), containsString("Failed to batch delete blobs"));
+ assertEquals(exception, thrown.getCause());
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3KeyMapperTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3KeyMapperTest.java
new file mode 100644
index 0000000000..c28769214c
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3KeyMapperTest.java
@@ -0,0 +1,212 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.junit5.LogCaptureExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Unit tests for {@link S3KeyMapper}.
+ *
+ * @version $Id$
+ */
+class S3KeyMapperTest
+{
+ @RegisterExtension
+ private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.WARN);
+
+ @ParameterizedTest
+ @CsvSource({
+ ",path/to/blob,path/to/blob",
+ "'',path/to/blob,path/to/blob",
+ "' ',path/to/blob,path/to/blob",
+ "my-prefix,path/to/blob,my-prefix/path/to/blob",
+ "my/nested/prefix,path/to/blob,my/nested/prefix/path/to/blob",
+ "prefix,blob,prefix/blob",
+ "prefix/,blob,prefix/blob",
+ "' //prefix// ',blob,prefix/blob"
+ })
+ void buildS3Key(String keyPrefix, String blobPathStr, String expectedS3Key)
+ {
+ S3KeyMapper mapper = new S3KeyMapper(keyPrefix);
+ BlobPath path = BlobPath.from(blobPathStr);
+
+ String s3Key = mapper.buildS3Key(path);
+
+ assertEquals(expectedS3Key, s3Key);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "prefix,path/to,prefix/path/to/",
+ "prefix,path/to/,prefix/path/to/",
+ ",path/to,path/to/",
+ "prefix,'',prefix/",
+ "prefix/,blob/,prefix/blob/"
+ })
+ void getS3KeyPrefix(String keyPrefix, String blobPathStr, String expectedPrefix)
+ {
+ S3KeyMapper mapper = new S3KeyMapper(keyPrefix);
+ BlobPath path = BlobPath.from(blobPathStr);
+
+ String prefix = mapper.getS3KeyPrefix(path);
+
+ assertEquals(expectedPrefix, prefix);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ ",path/to/blob,path/to/blob",
+ "'',path/to/blob,path/to/blob",
+ "my-prefix,my-prefix/path/to/blob,path/to/blob",
+ "my/nested/prefix,my/nested/prefix/path/to/blob,path/to/blob"
+ })
+ void s3KeyToBlobPathValid(String keyPrefix, String s3Key, String expectedBlobPath)
+ {
+ S3KeyMapper mapper = new S3KeyMapper(keyPrefix);
+
+ BlobPath path = mapper.s3KeyToBlobPath(s3Key);
+
+ assertNotNull(path);
+ assertEquals(expectedBlobPath, path.toString());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "expected-prefix,different-prefix/path/to/blob",
+ "prefix,prefix-extended/path/to/blob"
+ })
+ void s3KeyToBlobPathWithMismatchedPrefix(String keyPrefix, String s3Key)
+ {
+ S3KeyMapper mapper = new S3KeyMapper(keyPrefix);
+
+ BlobPath path = mapper.s3KeyToBlobPath(s3Key);
+
+ assertNull(path);
+ }
+
+ @Test
+ void s3KeyToBlobPathWithInvalidPath()
+ {
+ S3KeyMapper mapper = new S3KeyMapper("prefix");
+ String s3Key = "prefix/invalid/../path";
+
+ BlobPath path = mapper.s3KeyToBlobPath(s3Key);
+
+ assertNull(path);
+ assertEquals("Invalid blob path from S3 key: prefix/invalid/../path", this.logCapture.getMessage(0));
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"prefix", "my/nested/prefix"})
+ void s3KeyToBlobPathRoundTrip(String keyPrefix)
+ {
+ S3KeyMapper mapper = new S3KeyMapper(keyPrefix);
+ BlobPath originalPath = BlobPath.from("path/to/blob");
+
+ String s3Key = mapper.buildS3Key(originalPath);
+ BlobPath reconstructedPath = mapper.s3KeyToBlobPath(s3Key);
+
+ assertNotNull(reconstructedPath);
+ assertEquals(originalPath.toString(), reconstructedPath.toString());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"prefix", "my/nested/prefix"})
+ @NullAndEmptySource
+ void equalsWithSamePrefix(String prefix)
+ {
+ S3KeyMapper mapper1 = new S3KeyMapper(prefix);
+ S3KeyMapper mapper2 = new S3KeyMapper(prefix);
+
+ assertEquals(mapper1, mapper2);
+ assertEquals(mapper1.hashCode(), mapper2.hashCode());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {" prefix ", " /prefix/ ", " //prefix/", "/prefix/"})
+ void equalsWithEquivalentPrefixes(String prefix)
+ {
+ S3KeyMapper mapper1 = new S3KeyMapper(prefix);
+ S3KeyMapper mapper2 = new S3KeyMapper("prefix");
+
+ assertEquals(mapper1, mapper2);
+ assertEquals(mapper1.hashCode(), mapper2.hashCode());
+ }
+
+ @Test
+ void equalsWithDifferentPrefix()
+ {
+ S3KeyMapper mapper1 = new S3KeyMapper("prefix1");
+ S3KeyMapper mapper2 = new S3KeyMapper("prefix2");
+
+ assertNotEquals(mapper1, mapper2);
+ }
+
+ @Test
+ void equalsWithSameInstance()
+ {
+ S3KeyMapper mapper = new S3KeyMapper("prefix");
+
+ assertEquals(mapper, mapper);
+ }
+
+ @Test
+ void equalsWithNull()
+ {
+ S3KeyMapper mapper = new S3KeyMapper("prefix");
+
+ assertNotEquals(null, mapper);
+ }
+
+ @Test
+ void equalsWithDifferentClass()
+ {
+ S3KeyMapper mapper = new S3KeyMapper("prefix");
+ String other = "not a mapper";
+
+ assertNotEquals(mapper, other);
+ }
+
+ @Test
+ void hashCodeConsistency()
+ {
+ S3KeyMapper mapper = new S3KeyMapper("prefix");
+
+ int hashCode1 = mapper.hashCode();
+ int hashCode2 = mapper.hashCode();
+
+ assertEquals(hashCode1, hashCode2);
+ }
+}
diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3MultipartUploadHelperTest.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3MultipartUploadHelperTest.java
new file mode 100644
index 0000000000..7f1f932471
--- /dev/null
+++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-s3/src/test/java/org/xwiki/store/blob/internal/S3MultipartUploadHelperTest.java
@@ -0,0 +1,519 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.store.blob.internal;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.xwiki.store.blob.BlobDoesNotExistCondition;
+import org.xwiki.store.blob.BlobPath;
+import org.xwiki.store.blob.WriteCondition;
+import org.xwiki.store.blob.WriteConditionFailedException;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.junit5.LogCaptureExtension;
+
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link S3MultipartUploadHelper}.
+ *
+ * @version $Id$
+ */
+@ExtendWith(MockitoExtension.class)
+class S3MultipartUploadHelperTest
+{
+ private static final String BUCKET_NAME = "test-bucket";
+
+ private static final String S3_KEY = "test/key";
+
+ private static final String UPLOAD_ID = "test-upload-id";
+
+ @Mock
+ private S3Client s3Client;
+
+ @Mock
+ private BlobPath blobPath;
+
+ @RegisterExtension
+ private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.DEBUG);
+
+ @BeforeEach
+ void setUp()
+ {
+ CreateMultipartUploadResponse createResponse = CreateMultipartUploadResponse.builder()
+ .uploadId(UPLOAD_ID)
+ .build();
+
+ lenient().when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenReturn(createResponse);
+ }
+
+ @Test
+ void constructorInitializesMultipartUpload() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ assertNotNull(helper.getUploadId());
+ assertEquals(UPLOAD_ID, helper.getUploadId());
+ verify(this.s3Client).createMultipartUpload(any(CreateMultipartUploadRequest.class));
+
+ assertInitializationLog(0);
+ }
+
+ @Test
+ void constructorThrowsIOExceptionWhenInitializationFails()
+ {
+ when(this.s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class)))
+ .thenThrow(S3Exception.builder().message("S3 error").build());
+
+ IOException exception = assertThrows(IOException.class, () -> {
+ new S3MultipartUploadHelper(BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+ });
+
+ assertTrue(exception.getMessage().contains("Failed to initialize multipart upload"));
+ }
+
+ @Test
+ void getNextPartNumberReturnsSequentialNumbers() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ assertEquals(1, helper.getNextPartNumber());
+ helper.addCompletedPart("etag1");
+ assertEquals(2, helper.getNextPartNumber());
+ helper.addCompletedPart("etag2");
+ assertEquals(3, helper.getNextPartNumber());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletedPartLog(2, 2);
+ }
+
+ @Test
+ void getNextPartNumberThrowsWhenExceedingMaxParts() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ // Add MAX_PARTS parts
+ for (int i = 0; i < S3MultipartUploadHelper.MAX_PARTS; i++) {
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag" + i);
+ }
+
+ // Try to get part 10001
+ IOException exception = assertThrows(IOException.class, helper::getNextPartNumber);
+ assertEquals(
+ "Exceeded maximum number of parts (10000) for multipart upload. "
+ + "Consider increasing the part size to reduce the number of parts.",
+ exception.getMessage());
+
+ assertInitializationLog(0);
+ for (int i = 1; i <= S3MultipartUploadHelper.MAX_PARTS; i++) {
+ assertCompletedPartLog(i, i);
+ }
+ }
+
+ @Test
+ void addCompletedPartAddsPartInOrder() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag2");
+
+ // Verify by completing the upload and checking the request
+ helper.complete();
+
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(captor.capture());
+
+ CompleteMultipartUploadRequest request = captor.getValue();
+ assertEquals(2, request.multipartUpload().parts().size());
+ assertEquals("etag1", request.multipartUpload().parts().get(0).eTag());
+ assertEquals("etag2", request.multipartUpload().parts().get(1).eTag());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletedPartLog(2, 2);
+ assertCompletionLog(3);
+ }
+
+ @Test
+ void completeSuccessfullyCompletesUpload() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.complete();
+
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(captor.capture());
+
+ CompleteMultipartUploadRequest request = captor.getValue();
+ assertEquals(BUCKET_NAME, request.bucket());
+ assertEquals(S3_KEY, request.key());
+ assertEquals(UPLOAD_ID, request.uploadId());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ @Test
+ void completeWithCustomizerAppliesCustomization() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+
+ Consumer customizer = builder ->
+ builder.requestPayer("requester");
+
+ helper.complete(customizer);
+
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(captor.capture());
+
+ CompleteMultipartUploadRequest request = captor.getValue();
+ assertEquals("requester", request.requestPayerAsString());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ @Test
+ void completeWithBlobDoesNotExistConditionAddsIfNoneMatch() throws IOException
+ {
+ List conditions = new ArrayList<>();
+ conditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, conditions);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.complete();
+
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).completeMultipartUpload(captor.capture());
+
+ CompleteMultipartUploadRequest request = captor.getValue();
+ assertEquals("*", request.ifNoneMatch());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ @Test
+ void completeThrowsWriteConditionFailedExceptionOn412WithCondition() throws IOException
+ {
+ List conditions = new ArrayList<>();
+ conditions.add(BlobDoesNotExistCondition.INSTANCE);
+
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, conditions);
+
+ S3Exception s3Exception = (S3Exception) S3Exception.builder()
+ .message("Precondition failed")
+ .statusCode(412)
+ .build();
+
+ when(this.s3Client.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
+ .thenThrow(s3Exception);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+
+ IOException exception = assertThrows(IOException.class, helper::complete);
+ assertTrue(exception.getMessage().contains("Write condition failed"));
+ assertInstanceOf(WriteConditionFailedException.class, exception.getCause());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ }
+
+ @Test
+ void completeThrowsIOExceptionOnS3Error() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ when(this.s3Client.completeMultipartUpload(any(CompleteMultipartUploadRequest.class)))
+ .thenThrow(S3Exception.builder().message("S3 error").statusCode(500).build());
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+
+ IOException exception = assertThrows(IOException.class, helper::complete);
+ assertTrue(exception.getMessage().contains("S3 operation failed"));
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ }
+
+ @Test
+ void completeThrowsWhenAlreadyCompleted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.complete();
+
+ IOException exception = assertThrows(IOException.class, helper::complete);
+ assertEquals("Multipart upload already completed", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ @Test
+ void abortAbortsMultipartUpload() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.abort();
+
+ ArgumentCaptor captor = ArgumentCaptor.captor();
+ verify(this.s3Client).abortMultipartUpload(captor.capture());
+
+ AbortMultipartUploadRequest request = captor.getValue();
+ assertEquals(BUCKET_NAME, request.bucket());
+ assertEquals(S3_KEY, request.key());
+ assertEquals(UPLOAD_ID, request.uploadId());
+
+ assertInitializationLog(0);
+ assertAbortLog(1);
+ }
+
+ @Test
+ void abortIsIdempotent() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.abort();
+ helper.abort();
+ helper.abort();
+
+ verify(this.s3Client, times(1)).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
+
+ assertInitializationLog(0);
+ assertAbortLog(1);
+ }
+
+ @Test
+ void abortLogsWarningOnFailure() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ when(this.s3Client.abortMultipartUpload(any(AbortMultipartUploadRequest.class)))
+ .thenThrow(new RuntimeException("Abort failed"));
+
+ helper.abort();
+
+ // Verify no exception is thrown and warning is logged
+ verify(this.s3Client).abortMultipartUpload(any(AbortMultipartUploadRequest.class));
+
+ assertInitializationLog(0);
+ assertFailedAbortLog(1);
+ }
+
+ @Test
+ void getNextPartNumberThrowsWhenAborted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.abort();
+
+ IOException exception = assertThrows(IOException.class, helper::getNextPartNumber);
+ assertEquals("Multipart upload has been aborted", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertAbortLog(1);
+ }
+
+ @Test
+ void addCompletedPartThrowsWhenAborted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.abort();
+
+ IOException exception = assertThrows(IOException.class, () -> helper.addCompletedPart("etag"));
+ assertEquals("Multipart upload has been aborted", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertAbortLog(1);
+ }
+
+ @Test
+ void completeThrowsWhenAborted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.abort();
+
+ IOException exception = assertThrows(IOException.class, helper::complete);
+ assertEquals("Multipart upload has been aborted", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertAbortLog(1);
+ }
+
+ @Test
+ void addCompletedPartThrowsWhenCompleted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.complete();
+
+ IOException exception = assertThrows(IOException.class, () -> helper.addCompletedPart("etag2"));
+ assertEquals("Multipart upload already completed", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ @Test
+ void getNextPartNumberThrowsWhenCompleted() throws IOException
+ {
+ S3MultipartUploadHelper helper = new S3MultipartUploadHelper(
+ BUCKET_NAME, S3_KEY, this.s3Client, this.blobPath, null);
+
+ helper.getNextPartNumber();
+ helper.addCompletedPart("etag1");
+ helper.complete();
+
+ IOException exception = assertThrows(IOException.class, helper::getNextPartNumber);
+ assertEquals("Multipart upload already completed", exception.getMessage());
+
+ assertInitializationLog(0);
+ assertCompletedPartLog(1, 1);
+ assertCompletionLog(2);
+ }
+
+ // Helper methods for log assertions
+
+ /**
+ * Asserts the initialization log message at the specified index.
+ *
+ * @param index the log message index
+ */
+ private void assertInitializationLog(int index)
+ {
+ assertEquals("Initialized multipart upload for key test/key with upload ID: test-upload-id",
+ this.logCapture.getMessage(index));
+ }
+
+ /**
+ * Asserts the completed part log message at the specified index.
+ *
+ * @param index the log message index
+ * @param partNumber the part number that was completed
+ */
+ private void assertCompletedPartLog(int index, int partNumber)
+ {
+ assertEquals(String.format("Added completed part %d for upload ID: test-upload-id", partNumber),
+ this.logCapture.getMessage(index));
+ }
+
+ /**
+ * Asserts the completion log message at the specified index.
+ *
+ * @param index the log message index
+ */
+ private void assertCompletionLog(int index)
+ {
+ assertEquals("Completed multipart upload for key test/key with upload ID: test-upload-id",
+ this.logCapture.getMessage(index));
+ }
+
+ /**
+ * Asserts the abort log message at the specified index.
+ *
+ * @param index the log message index
+ */
+ private void assertAbortLog(int index)
+ {
+ assertEquals("Aborted multipart upload for key test/key with upload ID: test-upload-id",
+ this.logCapture.getMessage(index));
+ }
+
+ /**
+ * Asserts the failed abort warning log message at the specified index.
+ *
+ * @param index the log message index
+ */
+ private void assertFailedAbortLog(int index)
+ {
+ String message = this.logCapture.getMessage(index);
+ assertTrue(message.startsWith("Failed to abort multipart upload for blob at path"));
+ assertTrue(message.contains("test-upload-id"));
+ }
+}