diff --git a/Module/PSMinIO/bin/PSMinIO.dll b/Module/PSMinIO/bin/PSMinIO.dll
index 2a3c20b..9e613b9 100644
Binary files a/Module/PSMinIO/bin/PSMinIO.dll and b/Module/PSMinIO/bin/PSMinIO.dll differ
diff --git a/src/Cmdlets/NewMinIOBucketFolderCmdlet.cs b/src/Cmdlets/NewMinIOBucketFolderCmdlet.cs
new file mode 100644
index 0000000..c521c8c
--- /dev/null
+++ b/src/Cmdlets/NewMinIOBucketFolderCmdlet.cs
@@ -0,0 +1,212 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Management.Automation;
+using PSMinIO.Core.Models;
+using PSMinIO.Utils;
+
+namespace PSMinIO.Cmdlets
+{
+ ///
+ /// Creates bucket folder structure with support for multi-level paths
+ ///
+ [Cmdlet(VerbsCommon.New, "MinIOBucketFolder", SupportsShouldProcess = true)]
+ [OutputType(typeof(MinIOObjectInfo))]
+ public class NewMinIOBucketFolderCmdlet : MinIOBaseCmdlet
+ {
+ ///
+ /// Name of the bucket where the folder should be created
+ ///
+ [Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
+ [ValidateNotNullOrEmpty]
+ public string BucketName { get; set; } = string.Empty;
+
+ ///
+ /// Folder path to create (supports multiple formats: "/Folder", "Folder1/Folder2/Folder3", "Folder", "\Folder\Folder1\Folder3")
+ ///
+ [Parameter(Position = 1, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
+ [ValidateNotNullOrEmpty]
+ [Alias("Path", "Directory", "Prefix")]
+ public string FolderPath { get; set; } = string.Empty;
+
+ ///
+ /// Create parent folders if they don't exist (recursive creation)
+ ///
+ [Parameter]
+ public SwitchParameter Recursive { get; set; } = true;
+
+ ///
+ /// Overwrite existing folder without prompting
+ ///
+ [Parameter]
+ public SwitchParameter Force { get; set; }
+
+ ///
+ /// Processes the cmdlet
+ ///
+ protected override void ProcessRecord()
+ {
+ ValidateBucketName(BucketName);
+
+ // Sanitize the folder path
+ var sanitizedPath = SanitizeBucketDirectory(FolderPath);
+ if (string.IsNullOrEmpty(sanitizedPath))
+ {
+ var errorRecord = new ErrorRecord(
+ new ArgumentException("Invalid folder path after sanitization"),
+ "InvalidFolderPath",
+ ErrorCategory.InvalidArgument,
+ FolderPath);
+ ThrowTerminatingError(errorRecord);
+ }
+
+ var folderObjectName = $"{sanitizedPath}/";
+
+ if (ShouldProcess(folderObjectName, $"Create folder in bucket '{BucketName}'"))
+ {
+ var result = ExecuteOperation("CreateBucketFolder", () =>
+ {
+ // Check if bucket exists first
+ if (!S3Client.BucketExists(BucketName))
+ {
+ throw new InvalidOperationException($"Bucket '{BucketName}' does not exist");
+ }
+
+ WriteVerboseMessage("Creating bucket folder: {0} in bucket: {1}", sanitizedPath, BucketName);
+
+ // Check if folder already exists
+ var existingObjects = S3Client.ListObjects(BucketName, folderObjectName, false);
+ var folderExists = existingObjects.Any(obj => obj.Name == folderObjectName);
+
+ if (folderExists && !Force.IsPresent)
+ {
+ throw new InvalidOperationException($"Folder '{sanitizedPath}' already exists in bucket '{BucketName}'. Use -Force to overwrite.");
+ }
+
+ // Create folder structure recursively if requested
+ if (Recursive.IsPresent)
+ {
+ EnsureBucketDirectoryExists(sanitizedPath);
+ }
+ else
+ {
+ // Create only the final folder
+ CreateSingleFolder(folderObjectName);
+ }
+
+ // Return the created folder as MinIOObjectInfo
+ var folderInfo = new MinIOObjectInfo
+ {
+ Name = folderObjectName,
+ BucketName = BucketName,
+ Size = 0,
+ LastModified = DateTime.UtcNow,
+ ContentType = "application/x-directory",
+ ETag = "",
+ StartTime = DateTime.UtcNow,
+ CompletionTime = DateTime.UtcNow
+ };
+
+ WriteVerboseMessage("Successfully created bucket folder: {0}", sanitizedPath);
+ return folderInfo;
+
+ }, $"Bucket: {BucketName}, Folder: {sanitizedPath}");
+
+ WriteObject(result);
+ }
+ }
+
+ ///
+ /// Sanitizes bucket directory path and ensures proper format
+ /// Handles paths like "/Folder1/Folder2/Folder3" or "Folder1\Folder2\Folder3"
+ ///
+ /// Raw directory path
+ /// Sanitized directory path with forward slashes
+ private string SanitizeBucketDirectory(string directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ return string.Empty;
+
+ // Remove leading and trailing whitespace and slashes
+ var cleanDirectory = directory.Trim().Trim('/', '\\');
+
+ if (string.IsNullOrEmpty(cleanDirectory))
+ return string.Empty;
+
+ // Split by both forward and back slashes, then clean each part
+ var parts = cleanDirectory.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
+
+ // Clean each part: trim whitespace, remove invalid characters, ensure not empty
+ var cleanedParts = parts
+ .Select(part => part.Trim())
+ .Where(part => !string.IsNullOrEmpty(part))
+ .Select(part => part.Replace("\\", "").Replace("/", "")) // Remove any remaining slashes
+ .Where(part => !string.IsNullOrEmpty(part)); // Filter again after cleaning
+
+ // Join with forward slashes (S3/MinIO standard)
+ var result = string.Join("/", cleanedParts);
+
+ WriteVerboseMessage("Sanitized bucket directory: '{0}' -> '{1}'", directory, result);
+ return result;
+ }
+
+ ///
+ /// Creates bucket directory structure recursively if it doesn't exist
+ ///
+ /// Directory path to create
+ private void EnsureBucketDirectoryExists(string directoryPath)
+ {
+ if (string.IsNullOrWhiteSpace(directoryPath))
+ return;
+
+ var parts = directoryPath.Split('/');
+ var currentPath = string.Empty;
+
+ foreach (var part in parts)
+ {
+ currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
+ var folderPath = $"{currentPath}/";
+
+ try
+ {
+ // Check if this directory level already exists
+ var existingObjects = S3Client.ListObjects(BucketName, folderPath, false);
+ var folderExists = existingObjects.Any(obj => obj.Name == folderPath);
+
+ if (!folderExists)
+ {
+ CreateSingleFolder(folderPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ WriteVerboseMessage("Could not check directory existence: {0} - {1}", folderPath, ex.Message);
+ // Try to create anyway
+ CreateSingleFolder(folderPath);
+ }
+ }
+ }
+
+ ///
+ /// Creates a single folder by uploading an empty object with trailing slash
+ ///
+ /// Folder path with trailing slash
+ private void CreateSingleFolder(string folderPath)
+ {
+ try
+ {
+ WriteVerboseMessage("Creating bucket directory: {0}", folderPath);
+ using (var emptyStream = new MemoryStream())
+ {
+ S3Client.PutObject(BucketName, folderPath, emptyStream, "application/x-directory", null, null);
+ }
+ WriteVerboseMessage("Successfully created bucket directory: {0}", folderPath);
+ }
+ catch (Exception createEx)
+ {
+ WriteVerboseMessage("Directory creation failed: {0} - {1}", folderPath, createEx.Message);
+ throw new InvalidOperationException($"Failed to create folder '{folderPath}': {createEx.Message}", createEx);
+ }
+ }
+ }
+}
diff --git a/src/Cmdlets/NewMinIOObjectMultipartCmdlet.cs b/src/Cmdlets/NewMinIOObjectMultipartCmdlet.cs
index ab7f12c..c88a232 100644
--- a/src/Cmdlets/NewMinIOObjectMultipartCmdlet.cs
+++ b/src/Cmdlets/NewMinIOObjectMultipartCmdlet.cs
@@ -55,6 +55,14 @@ namespace PSMinIO.Cmdlets
[ValidateRange(1, 10)]
public int MaxParallelUploads { get; set; } = 4;
+ ///
+ /// Optional bucket directory path where files should be uploaded
+ ///
+ [Parameter]
+ [ValidateNotNullOrEmpty]
+ [Alias("Prefix", "Folder")]
+ public string? BucketDirectory { get; set; }
+
///
/// Content type of the object (auto-detected if not specified)
///
@@ -106,6 +114,16 @@ namespace PSMinIO.Cmdlets
// Determine object name
var effectiveObjectName = ObjectName ?? FilePath.Name;
+ // Apply bucket directory prefix if specified
+ if (!string.IsNullOrWhiteSpace(BucketDirectory))
+ {
+ var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory!);
+ if (!string.IsNullOrEmpty(sanitizedDirectory))
+ {
+ effectiveObjectName = $"{sanitizedDirectory}/{effectiveObjectName}";
+ }
+ }
+
// Auto-detect content type if not specified
var effectiveContentType = ContentType ?? GetContentType(FilePath.Extension);
@@ -124,9 +142,26 @@ namespace PSMinIO.Cmdlets
{
var result = ExecuteOperation("MultipartUpload", () =>
{
- WriteVerboseMessage("Starting multipart upload: {0} -> {1}/{2}",
+ // Check if bucket exists first
+ if (!S3Client.BucketExists(BucketName))
+ {
+ throw new InvalidOperationException($"Bucket '{BucketName}' does not exist");
+ }
+
+ // Create bucket directory structure if specified
+ if (!string.IsNullOrWhiteSpace(BucketDirectory))
+ {
+ var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory!);
+ if (!string.IsNullOrEmpty(sanitizedDirectory))
+ {
+ WriteVerboseMessage("Ensuring bucket directory exists: {0}", sanitizedDirectory);
+ EnsureBucketDirectoryExists(sanitizedDirectory);
+ }
+ }
+
+ WriteVerboseMessage("Starting multipart upload: {0} -> {1}/{2}",
FilePath.FullName, BucketName, effectiveObjectName);
- WriteVerboseMessage("File size: {0}, Chunk size: {1}, Max parallel uploads: {2}",
+ WriteVerboseMessage("File size: {0}, Chunk size: {1}, Max parallel uploads: {2}",
SizeFormatter.FormatBytes(FilePath.Length), SizeFormatter.FormatBytes(ChunkSize), MaxParallelUploads);
if (!string.IsNullOrEmpty(ResumeUploadId))
@@ -208,5 +243,89 @@ namespace PSMinIO.Cmdlets
_ => "application/octet-stream"
};
}
+
+ ///
+ /// Sanitizes bucket directory path and ensures proper format
+ /// Handles paths like "/Folder1/Folder2/Folder3" or "Folder1\Folder2\Folder3"
+ ///
+ /// Raw directory path
+ /// Sanitized directory path with forward slashes
+ private string SanitizeBucketDirectory(string directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ return string.Empty;
+
+ // Remove leading and trailing whitespace and slashes
+ var cleanDirectory = directory.Trim().Trim('/', '\\');
+
+ if (string.IsNullOrEmpty(cleanDirectory))
+ return string.Empty;
+
+ // Split by both forward and back slashes, then clean each part
+ var parts = cleanDirectory.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
+
+ // Clean each part: trim whitespace, remove invalid characters, ensure not empty
+ var cleanedParts = parts
+ .Select(part => part.Trim())
+ .Where(part => !string.IsNullOrEmpty(part))
+ .Select(part => part.Replace("\\", "").Replace("/", "")) // Remove any remaining slashes
+ .Where(part => !string.IsNullOrEmpty(part)); // Filter again after cleaning
+
+ // Join with forward slashes (S3/MinIO standard)
+ var result = string.Join("/", cleanedParts);
+
+ WriteVerboseMessage("Sanitized bucket directory: '{0}' -> '{1}'", directory, result);
+ return result;
+ }
+
+ ///
+ /// Creates bucket directory structure if it doesn't exist
+ ///
+ /// Directory path to create
+ private void EnsureBucketDirectoryExists(string directoryPath)
+ {
+ if (string.IsNullOrWhiteSpace(directoryPath))
+ return;
+
+ var parts = directoryPath.Split('/');
+ var currentPath = string.Empty;
+
+ foreach (var part in parts)
+ {
+ currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
+ var folderPath = $"{currentPath}/";
+
+ try
+ {
+ // Check if this directory level already exists by trying to list objects with this prefix
+ var existingObjects = S3Client.ListObjects(BucketName, folderPath, false);
+ var folderExists = existingObjects.Any(obj => obj.Name == folderPath);
+
+ if (!folderExists)
+ {
+ WriteVerboseMessage("Creating bucket directory: {0}", folderPath);
+ try
+ {
+ // Create the directory by uploading an empty object with trailing slash
+ using (var emptyStream = new MemoryStream())
+ {
+ S3Client.PutObject(BucketName, folderPath, emptyStream, "application/x-directory", null, null);
+ }
+ WriteVerboseMessage("Successfully created bucket directory: {0}", folderPath);
+ }
+ catch (Exception createEx)
+ {
+ // Directory creation failed, but this is not critical since MinIO creates directories implicitly
+ WriteVerboseMessage("Directory creation failed (non-critical): {0} - {1}", folderPath, createEx.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Listing failed, but this is not critical for the upload operation
+ WriteVerboseMessage("Could not check directory existence (non-critical): {0} - {1}", folderPath, ex.Message);
+ }
+ }
+ }
}
}
diff --git a/src/Cmdlets/RemoveMinIOBucketFolderCmdlet.cs b/src/Cmdlets/RemoveMinIOBucketFolderCmdlet.cs
new file mode 100644
index 0000000..51952ef
--- /dev/null
+++ b/src/Cmdlets/RemoveMinIOBucketFolderCmdlet.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Linq;
+using System.Management.Automation;
+using PSMinIO.Core.Models;
+using PSMinIO.Utils;
+
+namespace PSMinIO.Cmdlets
+{
+ ///
+ /// Removes bucket folder structure with support for recursive deletion
+ ///
+ [Cmdlet(VerbsCommon.Remove, "MinIOBucketFolder", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(MinIOObjectInfo))]
+ public class RemoveMinIOBucketFolderCmdlet : MinIOBaseCmdlet
+ {
+ ///
+ /// Name of the bucket containing the folder to remove
+ ///
+ [Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
+ [ValidateNotNullOrEmpty]
+ public string BucketName { get; set; } = string.Empty;
+
+ ///
+ /// Folder path to remove (supports multiple formats: "/Folder", "Folder1/Folder2/Folder3", "Folder", "\Folder\Folder1\Folder3")
+ ///
+ [Parameter(Position = 1, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
+ [ValidateNotNullOrEmpty]
+ [Alias("Path", "Directory", "Prefix")]
+ public string FolderPath { get; set; } = string.Empty;
+
+ ///
+ /// Remove folder and all its contents recursively
+ ///
+ [Parameter]
+ public SwitchParameter Recursive { get; set; }
+
+ ///
+ /// Remove folder without prompting for confirmation
+ ///
+ [Parameter]
+ public SwitchParameter Force { get; set; }
+
+ ///
+ /// Processes the cmdlet
+ ///
+ protected override void ProcessRecord()
+ {
+ ValidateBucketName(BucketName);
+
+ // Sanitize the folder path
+ var sanitizedPath = SanitizeBucketDirectory(FolderPath);
+ if (string.IsNullOrEmpty(sanitizedPath))
+ {
+ var errorRecord = new ErrorRecord(
+ new ArgumentException("Invalid folder path after sanitization"),
+ "InvalidFolderPath",
+ ErrorCategory.InvalidArgument,
+ FolderPath);
+ ThrowTerminatingError(errorRecord);
+ }
+
+ var folderObjectName = $"{sanitizedPath}/";
+
+ if (ShouldProcess(folderObjectName, $"Remove folder from bucket '{BucketName}'"))
+ {
+ var result = ExecuteOperation("RemoveBucketFolder", () =>
+ {
+ // Check if bucket exists first
+ if (!S3Client.BucketExists(BucketName))
+ {
+ throw new InvalidOperationException($"Bucket '{BucketName}' does not exist");
+ }
+
+ WriteVerboseMessage("Removing bucket folder: {0} from bucket: {1}", sanitizedPath, BucketName);
+
+ // Check if folder exists
+ var existingObjects = S3Client.ListObjects(BucketName, folderObjectName, false);
+ var folderExists = existingObjects.Any(obj => obj.Name == folderObjectName);
+
+ if (!folderExists)
+ {
+ WriteVerboseMessage("Folder '{0}' does not exist in bucket '{1}'", sanitizedPath, BucketName);
+ return null;
+ }
+
+ // Get folder info before deletion
+ var folderInfo = new MinIOObjectInfo
+ {
+ Name = folderObjectName,
+ BucketName = BucketName,
+ Size = 0,
+ LastModified = DateTime.UtcNow,
+ ContentType = "application/x-directory",
+ ETag = "",
+ StartTime = DateTime.UtcNow
+ };
+
+ if (Recursive.IsPresent)
+ {
+ // Remove folder and all contents recursively
+ RemoveFolderRecursively(sanitizedPath);
+ }
+ else
+ {
+ // Check if folder has contents
+ var folderContents = S3Client.ListObjects(BucketName, sanitizedPath + "/", true);
+ var hasContents = folderContents.Any(obj => obj.Name != folderObjectName);
+
+ if (hasContents && !Force.IsPresent)
+ {
+ throw new InvalidOperationException($"Folder '{sanitizedPath}' is not empty. Use -Recursive to remove contents or -Force to ignore this check.");
+ }
+
+ // Remove only the folder marker
+ RemoveSingleFolder(folderObjectName);
+ }
+
+ folderInfo.CompletionTime = DateTime.UtcNow;
+ WriteVerboseMessage("Successfully removed bucket folder: {0}", sanitizedPath);
+ return folderInfo;
+
+ }, $"Bucket: {BucketName}, Folder: {sanitizedPath}");
+
+ if (result != null)
+ {
+ WriteObject(result);
+ }
+ }
+ }
+
+ ///
+ /// Sanitizes bucket directory path and ensures proper format
+ ///
+ private string SanitizeBucketDirectory(string directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ return string.Empty;
+
+ // Remove leading and trailing whitespace and slashes
+ var cleanDirectory = directory.Trim().Trim('/', '\\');
+
+ if (string.IsNullOrEmpty(cleanDirectory))
+ return string.Empty;
+
+ // Split by both forward and back slashes, then clean each part
+ var parts = cleanDirectory.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
+
+ // Clean each part: trim whitespace, remove invalid characters, ensure not empty
+ var cleanedParts = parts
+ .Select(part => part.Trim())
+ .Where(part => !string.IsNullOrEmpty(part))
+ .Select(part => part.Replace("\\", "").Replace("/", "")) // Remove any remaining slashes
+ .Where(part => !string.IsNullOrEmpty(part)); // Filter again after cleaning
+
+ // Join with forward slashes (S3/MinIO standard)
+ var result = string.Join("/", cleanedParts);
+
+ WriteVerboseMessage("Sanitized bucket directory: '{0}' -> '{1}'", directory, result);
+ return result;
+ }
+
+ ///
+ /// Removes folder and all its contents recursively
+ ///
+ private void RemoveFolderRecursively(string folderPath)
+ {
+ WriteVerboseMessage("Removing folder recursively: {0}", folderPath);
+
+ // List all objects with the folder prefix
+ var objectsToDelete = S3Client.ListObjects(BucketName, folderPath + "/", true);
+
+ foreach (var obj in objectsToDelete)
+ {
+ try
+ {
+ WriteVerboseMessage("Removing object: {0}", obj.Name);
+ S3Client.DeleteObject(BucketName, obj.Name);
+ }
+ catch (Exception ex)
+ {
+ WriteVerboseMessage("Failed to remove object '{0}': {1}", obj.Name, ex.Message);
+ throw new InvalidOperationException($"Failed to remove object '{obj.Name}': {ex.Message}", ex);
+ }
+ }
+
+ WriteVerboseMessage("Successfully removed folder recursively: {0}", folderPath);
+ }
+
+ ///
+ /// Removes a single folder marker object
+ ///
+ private void RemoveSingleFolder(string folderObjectName)
+ {
+ try
+ {
+ WriteVerboseMessage("Removing folder marker: {0}", folderObjectName);
+ S3Client.DeleteObject(BucketName, folderObjectName);
+ WriteVerboseMessage("Successfully removed folder marker: {0}", folderObjectName);
+ }
+ catch (Exception ex)
+ {
+ WriteVerboseMessage("Failed to remove folder marker '{0}': {1}", folderObjectName, ex.Message);
+ throw new InvalidOperationException($"Failed to remove folder '{folderObjectName}': {ex.Message}", ex);
+ }
+ }
+ }
+}