diff --git a/pom.xml b/pom.xml index 09210d453a..bc8a85e81e 100644 --- a/pom.xml +++ b/pom.xml @@ -138,6 +138,9 @@ 2.0.15 + + 2.35.10 + false false @@ -1502,6 +1505,13 @@ jakarta.enterprise.cdi-api 2.0.2 + + software.amazon.awssdk + bom + ${aws.java.sdk.version} + pom + import + diff --git a/xwiki-commons-core/pom.xml b/xwiki-commons-core/pom.xml index 4c7184c2be..daac85f758 100644 --- a/xwiki-commons-core/pom.xml +++ b/xwiki-commons-core/pom.xml @@ -58,6 +58,7 @@ xwiki-commons-properties xwiki-commons-repository xwiki-commons-script + xwiki-commons-store xwiki-commons-stability xwiki-commons-text xwiki-commons-velocity diff --git a/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/pom.xml b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/pom.xml index 864f9ac81a..bf3fb21494 100644 --- a/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/pom.xml +++ b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/pom.xml @@ -50,6 +50,11 @@ xwiki-commons-job-api ${project.version} + + org.xwiki.commons + xwiki-commons-store-blob-api + ${project.version} + org.xwiki.commons diff --git a/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/input/DefaultStreamProviderInputSource.java b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/input/DefaultStreamProviderInputSource.java new file mode 100644 index 0000000000..4a52412d4a --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/input/DefaultStreamProviderInputSource.java @@ -0,0 +1,61 @@ +/* + * 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.filter.input; + +import java.io.IOException; +import java.io.InputStream; + +import org.xwiki.stability.Unstable; +import org.xwiki.store.StreamProvider; + +/** + * An input source based on a {@link StreamProvider}. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public class DefaultStreamProviderInputSource extends AbstractInputStreamInputSource +{ + private final StreamProvider streamProvider; + + /** + * Create a new input source based on the passed stream provider. + * + * @param streamProvider the stream provider to use + */ + public DefaultStreamProviderInputSource(StreamProvider streamProvider) + { + this.streamProvider = streamProvider; + } + + @Override + protected InputStream openStream() throws IOException + { + try { + return this.streamProvider.getStream(); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to open stream", e); + } + } + +} diff --git a/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/output/DefaultBlobOutputTarget.java b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/output/DefaultBlobOutputTarget.java new file mode 100644 index 0000000000..2063df06ae --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-filter/xwiki-commons-filter-api/src/main/java/org/xwiki/filter/output/DefaultBlobOutputTarget.java @@ -0,0 +1,58 @@ +/* + * 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.filter.output; + +import java.io.IOException; +import java.io.OutputStream; + +import org.xwiki.stability.Unstable; +import org.xwiki.store.blob.Blob; + +/** + * An implementation of {@link OutputStreamOutputTarget} that writes to a {@link Blob}. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public class DefaultBlobOutputTarget extends AbstractOutputStreamOutputTarget +{ + private final Blob blob; + + /** + * Create an instance of {@link OutputStreamOutputTarget} returning the passed {@link Blob}'s output stream. + * + * @param blob the {@link Blob} + */ + public DefaultBlobOutputTarget(Blob blob) + { + this.blob = blob; + } + + @Override + protected OutputStream openStream() throws IOException + { + try { + return this.blob.getOutputStream(); + } catch (Exception e) { + throw new IOException("Failed to open blob output stream", e); + } + } +} diff --git a/xwiki-commons-core/xwiki-commons-store/pom.xml b/xwiki-commons-core/xwiki-commons-store/pom.xml new file mode 100644 index 0000000000..35722b3bc9 --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/pom.xml @@ -0,0 +1,38 @@ + + + + + + 4.0.0 + + org.xwiki.commons + xwiki-commons-core + 17.10.0-SNAPSHOT + + xwiki-commons-store + XWiki Commons - Store + pom + XWiki Commons - Store + + xwiki-commons-store-api + xwiki-commons-store-blob + + diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/pom.xml b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/pom.xml new file mode 100644 index 0000000000..41f0ae1549 --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/pom.xml @@ -0,0 +1,51 @@ + + + + + + 4.0.0 + + org.xwiki.commons + xwiki-commons-store + 17.10.0-SNAPSHOT + + xwiki-commons-store-api + XWiki Commons - Store - API + jar + Storage API for accessing data in a storage backend + + 0.76 + + + + org.xwiki.commons + xwiki-commons-component-api + ${project.version} + + + + org.xwiki.commons + xwiki-commons-tool-test-simple + ${project.version} + test + + + diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StoreException.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StoreException.java new file mode 100644 index 0000000000..8de3c3fcbc --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StoreException.java @@ -0,0 +1,57 @@ +/* + * 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; + +import java.io.Serial; + +import org.xwiki.stability.Unstable; + +/** + * Base exception for store related APIs. + * + * @version $Id$ + * @since 17.1.0RC1 + */ +@Unstable +public class StoreException extends Exception +{ + /** + * Serialization identifier. + */ + @Serial + private static final long serialVersionUID = 1L; + + /** + * @param message exception message + */ + public StoreException(String message) + { + super(message); + } + + /** + * @param message exception message + * @param cause nested exception + */ + public StoreException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StreamProvider.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StreamProvider.java new file mode 100644 index 0000000000..561edeb4a6 --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-api/src/main/java/org/xwiki/store/StreamProvider.java @@ -0,0 +1,39 @@ +/* + * 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; + +import java.io.InputStream; + +/** + * A generic thing which provides access to an InputStream on demand. + * + * @version $Id$ + * @since 3.0M2 + */ +public interface StreamProvider +{ + /** + * Get the stream provided by this StreamProvider. + * + * @return the stream which this StreamProvider provides. + * @throws Exception if something goes wrong while trying to get the stream. + */ + InputStream getStream() throws Exception; +} diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/pom.xml b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/pom.xml new file mode 100644 index 0000000000..3227e35784 --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/pom.xml @@ -0,0 +1,39 @@ + + + + + + 4.0.0 + + org.xwiki.commons + xwiki-commons-store + 17.10.0-SNAPSHOT + + xwiki-commons-store-blob + XWiki Commons - Store - Blob + pom + Blob storage + + xwiki-commons-store-blob-api + xwiki-commons-store-blob-filesystem + xwiki-commons-store-blob-s3 + + diff --git a/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/pom.xml b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/pom.xml new file mode 100644 index 0000000000..d7a9dad0e6 --- /dev/null +++ b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/pom.xml @@ -0,0 +1,65 @@ + + + + + + 4.0.0 + + org.xwiki.commons + xwiki-commons-store-blob + 17.10.0-SNAPSHOT + + xwiki-commons-store-blob-api + XWiki Commons - Store - Blob - API + jar + Storage API for managing blobs of data. + + 0.75 + + + + org.xwiki.commons + xwiki-commons-component-api + ${project.version} + + + org.xwiki.commons + xwiki-commons-configuration-api + ${project.version} + + + org.xwiki.commons + xwiki-commons-store-api + ${project.version} + + + commons-io + commons-io + + + + 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-api/src/main/java/org/xwiki/store/blob/AbstractBlobStore.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/AbstractBlobStore.java new file mode 100644 index 0000000000..7631b04f6f --- /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/AbstractBlobStore.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; + +import java.util.List; +import java.util.stream.Stream; + +import org.xwiki.stability.Unstable; + +/** + * Abstract base class for {@link BlobStore} implementations. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public abstract class AbstractBlobStore implements BlobStore +{ + protected String name; + + /** + * Create a new blob store with the given name. + * + * @param name the name of this blob store + */ + protected AbstractBlobStore(String name) + { + this.name = name; + } + + @Override + public String getName() + { + return this.name; + } + + @Override + public void moveDirectory(BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException + { + moveDirectory(this, sourcePath, targetPath); + } + + @Override + public void moveDirectory(BlobStore sourceStore, BlobPath sourcePath, BlobPath targetPath) throws BlobStoreException + { + if (sourceStore.equals(this)) { + if (sourcePath.isAncestorOfOrEquals(targetPath)) { + throw new BlobStoreException("Cannot move a directory to inside itself"); + } else if (targetPath.isAncestorOfOrEquals(sourcePath)) { + throw new BlobStoreException("Cannot move a directory to one of its ancestors"); + } + } + try (Stream blobs = sourceStore.listBlobs(sourcePath)) { + int numSourceSegments = sourcePath.getSegments().size(); + for (Blob blob : (Iterable) blobs::iterator) { + List sourceSegments = blob.getPath().getSegments(); + List relativeSourceSegments = sourceSegments.subList(numSourceSegments, sourceSegments.size()); + BlobPath resolvedTargetPath = targetPath.resolve(relativeSourceSegments.toArray(new String[0])); + if (sourceStore == this) { + moveBlob(blob.getPath(), resolvedTargetPath); + } else { + moveBlob(sourceStore, blob.getPath(), resolvedTargetPath); + } + } + } + } +} 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/Blob.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/Blob.java new file mode 100644 index 0000000000..373cbadb46 --- /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/Blob.java @@ -0,0 +1,93 @@ +/* + * 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.InputStream; +import java.io.OutputStream; + +import org.xwiki.stability.Unstable; +import org.xwiki.store.StreamProvider; + +/** + * A Blob is a piece of data stored in a BlobStore. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public interface Blob extends StreamProvider +{ + /** + * @return the store where this blob is stored + */ + BlobStore getStore(); + + /** + * @return the path of this blob inside its store + */ + BlobPath getPath(); + + /** + * @return true if the blob exists, false otherwise + * @throws BlobStoreException when the existence of the blob cannot be determined + */ + boolean exists() throws BlobStoreException; + + /** + * Get the size of this blob. + * + * @return the size of this blob in bytes, or -1 if the blob doesn't exist + * @throws BlobStoreException when the size cannot be determined + */ + long getSize() throws BlobStoreException; + + /** + * Get an OutputStream to write data to this blob. The caller must close the returned stream after use. + * + * @param conditions the conditions that must be satisfied before writing to this blob + * @return an OutputStream to write data to this blob + * @throws BlobStoreException if the blob cannot be written, for example because its name is invalid. There is no + * guarantee that in such a case an exception will be thrown, the exception could also only be thrown when data + * is written to the stream, or when the stream is closed. + */ + OutputStream getOutputStream(WriteCondition... conditions) throws BlobStoreException; + + /** + * Write the content of the given InputStream to this blob. The given InputStream will be closed by this method. + * + * @param inputStream the InputStream to read data from + * @param conditions the conditions that must be satisfied before writing to this blob + * @throws BlobStoreException if the InputStream cannot be read or the blob cannot be written, for example because + * its name is invalid. + * @todo Recommend this method over {@link #getOutputStream(WriteCondition...)} once this is actually more than + * IOUtils#copy - or remove it, otherwise. + */ + void writeFromStream(InputStream inputStream, WriteCondition... conditions) throws BlobStoreException; + + /** + * Get an InputStream to read data from this blob. The caller must close the returned stream after use. + * + * @return an InputStream to read data from this blob + * @throws BlobStoreException if the blob cannot be read + * @throws BlobNotFoundException if the blob does not exist + */ + @Override + InputStream getStream() 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/BlobAlreadyExistsException.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobAlreadyExistsException.java new file mode 100644 index 0000000000..7aff67a84c --- /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/BlobAlreadyExistsException.java @@ -0,0 +1,75 @@ +/* + * 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; + +/** + * Exception thrown when trying to copy a blob to a target path that already contains a blob. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public class BlobAlreadyExistsException extends BlobStoreException +{ + /** + * Provides an id for serialization. + */ + @Serial + private static final long serialVersionUID = 1L; + + private static final String BLOB_ALREADY_EXISTS_MESSAGE = "Blob already exists at target path: "; + + private final BlobPath targetPath; + + /** + * Constructs a new exception with the specified target path. + * + * @param targetPath the path where a blob already exists + */ + public BlobAlreadyExistsException(BlobPath targetPath) + { + super(BLOB_ALREADY_EXISTS_MESSAGE + targetPath); + this.targetPath = targetPath; + } + + /** + * Constructs a new exception with the specified target path and cause. + * + * @param targetPath the path where a blob already exists + * @param cause the cause of the exception + */ + public BlobAlreadyExistsException(BlobPath targetPath, Throwable cause) + { + super(BLOB_ALREADY_EXISTS_MESSAGE + targetPath, cause); + this.targetPath = targetPath; + } + + /** + * @return the path where a blob already exists + */ + public BlobPath getTargetPath() + { + return this.targetPath; + } +} 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/BlobDoesNotExistCondition.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobDoesNotExistCondition.java new file mode 100644 index 0000000000..eaa8294687 --- /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/BlobDoesNotExistCondition.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** + * A write condition that requires the blob to not exist before writing. + * This ensures atomic create-only operations. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public final class BlobDoesNotExistCondition implements WriteCondition +{ + /** + * Singleton instance of this condition. + */ + public static final BlobDoesNotExistCondition INSTANCE = new BlobDoesNotExistCondition(); + + private BlobDoesNotExistCondition() + { + // Singleton pattern + } + + @Override + public String getDescription() + { + return "Blob must not exist"; + } + + @Override + public String toString() + { + return 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/BlobNotFoundException.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobNotFoundException.java new file mode 100644 index 0000000000..aeff5a61cd --- /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/BlobNotFoundException.java @@ -0,0 +1,75 @@ +/* + * 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; + +/** + * Exception thrown when a blob cannot be found at the specified path. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public class BlobNotFoundException extends BlobStoreException +{ + /** + * Provides an id for serialization. + */ + @Serial + private static final long serialVersionUID = 1L; + + private static final String BLOB_NOT_FOUND_MESSAGE = "Blob not found at path: "; + + private final BlobPath blobPath; + + /** + * Constructs a new exception with the specified blob path. + * + * @param blobPath the path of the blob that was not found + */ + public BlobNotFoundException(BlobPath blobPath) + { + super(BLOB_NOT_FOUND_MESSAGE + blobPath); + this.blobPath = blobPath; + } + + /** + * Constructs a new exception with the specified blob path and cause. + * + * @param blobPath the path of the blob that was not found + * @param cause the cause of the exception + */ + public BlobNotFoundException(BlobPath blobPath, Throwable cause) + { + super(BLOB_NOT_FOUND_MESSAGE + blobPath, cause); + this.blobPath = blobPath; + } + + /** + * @return the path of the blob that was not found + */ + public BlobPath getBlobPath() + { + return this.blobPath; + } +} 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/BlobPath.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobPath.java new file mode 100644 index 0000000000..1cd833fb13 --- /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/BlobPath.java @@ -0,0 +1,223 @@ +/* + * 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 java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.xwiki.stability.Unstable; + +/** + * An opaque identifier for blobs within a BlobStore. Segments are joined with '/' but implementations are free to + * interpret them as needed (e.g. S3 keys). BlobPaths are immutable. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public final class BlobPath +{ + /** + * The root BlobPath with no segments. + */ + public static final BlobPath ROOT = new BlobPath(List.of()); + + private final List segments; + + private final String canonical; + + private BlobPath(List segments) + { + // Validate segments to ensure each is a single file system component and disallow directory traversal. + if (segments == null) { + throw new IllegalArgumentException("segments must not be null"); + } + for (int i = 0; i < segments.size(); i++) { + String s = segments.get(i); + validateSegment(s, i); + } + this.segments = List.copyOf(segments); + this.canonical = String.join("/", segments); + } + + private static void validateSegment(String s, int i) + { + if (s == null) { + throw new IllegalArgumentException("Segment at index %d is null".formatted(i)); + } + if (s.isEmpty()) { + throw new IllegalArgumentException("Segment at index %d is empty".formatted(i)); + } + if (s.equals(".") || s.equals("..")) { + throw new IllegalArgumentException( + "Segment at index %d is a directory traversal component: %s".formatted(i, s)); + } + if (s.indexOf('/') >= 0 || s.indexOf('\\') >= 0) { + throw new IllegalArgumentException( + "Segment at index %d contains an illegal path separator: %s".formatted(i, s)); + } + } + + /** + * Create a BlobPath from individual segments. + * + * @param segments the segments of the path + * @return a BlobPath constructed from the segments + */ + public static BlobPath of(List segments) + { + return new BlobPath(segments); + } + + /** + * Create a BlobPath by splitting a slash-delimited string. + * + * @param path the slash-delimited path string + * @return a BlobPath constructed from the segments in the string + */ + public static BlobPath from(String path) + { + if (path == null) { + throw new IllegalArgumentException("path must not be null"); + } + if (path.isEmpty()) { + return BlobPath.ROOT; + } + String[] parts = StringUtils.split(path, '/'); + List nonEmpty = Arrays.stream(parts) + .filter(s -> !s.isEmpty()) + .toList(); + + return new BlobPath(nonEmpty); + } + + /** + * Return a new BlobPath by appending additional segments. + * + * @param moreSegments the segments to append to this path + * @return a new BlobPath with the additional segments appended + */ + public BlobPath resolve(String... moreSegments) + { + if (moreSegments.length == 0) { + return this; + } + List combined = + Stream.concat(this.segments.stream(), Arrays.stream(moreSegments)) + .toList(); + return new BlobPath(combined); + } + + /** + * @return the parent BlobPath. + */ + public BlobPath getParent() + { + if (this.segments.size() <= 1) { + return BlobPath.of(List.of()); + } + List parentSegments = this.segments.subList(0, this.segments.size() - 1); + return new BlobPath(parentSegments); + } + + /** + * @param suffix the suffix to append to the filename + * @return a new BlobPath with the suffix appended to the filename (last segment) + */ + public BlobPath appendSuffix(String suffix) + { + if (StringUtils.isBlank(suffix)) { + throw new IllegalArgumentException("Suffix must not be empty"); + } + + String lastSegment = getName() + suffix; + List newSegments = Stream.concat( + this.segments.stream().limit(this.segments.isEmpty() ? 0 : this.segments.size() - 1L), + Stream.of(lastSegment)) + .toList(); + return new BlobPath(newSegments); + } + + /** + * Check if this path is an ancestor of another path or equals it. + * + * @param other the other path to compare against + * @return true if this path is an ancestor of the other path or equals it, false otherwise + */ + public boolean isAncestorOfOrEquals(BlobPath other) + { + if (this.segments.size() > other.segments.size()) { + return false; + } + + for (int i = 0; i < this.segments.size(); i++) { + if (!this.segments.get(i).equals(other.segments.get(i))) { + return false; + } + } + + return true; + } + + /** + * @return the segments of this BlobPath as a list + */ + public List getSegments() + { + return this.segments; + } + + /** + * @return the name of the final segment of this BlobPath + */ + public String getName() + { + if (this.segments.isEmpty()) { + return ""; + } + return this.segments.get(this.segments.size() - 1); + } + + @Override + public String toString() + { + return this.canonical; + } + + @Override + public boolean equals(Object o) + { + if (this == o) { + return true; + } + if (!(o instanceof BlobPath other)) { + return false; + } + return this.canonical.equals(other.canonical); + } + + @Override + public int hashCode() + { + return this.canonical.hashCode(); + } +} 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/BlobStore.java b/xwiki-commons-core/xwiki-commons-store/xwiki-commons-store-blob/xwiki-commons-store-blob-api/src/main/java/org/xwiki/store/blob/BlobStore.java new file mode 100644 index 0000000000..54c8ab0b3a --- /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/BlobStore.java @@ -0,0 +1,162 @@ +/* + * 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.xwiki.stability.Unstable; + +/** + * A storage that allows storing blob data. + * + * @version $Id$ + * @since 17.10.0RC1 + */ +@Unstable +public interface BlobStore +{ + /** + * Get the name of this blob store. + * + * @return the name of this blob store + */ + String getName(); + + /** + * Get the blob with the given identifier. + * + * @param path the path of the blob to retrieve + * @return the blob with the given path + * @throws BlobStoreException if there cannot be a blob with the given path (e.g., because the path is too long) + */ + Blob getBlob(BlobPath path) throws BlobStoreException; + + /** + * List all blobs under the given path. The caller must close the returned stream after use. + * + * @param path the path prefix to search under + * @return an iterator over all blobs under the given path + * @throws BlobStoreException if the listing operation fails + */ + Stream 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")); + } +}