diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..14c5fbf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,115 @@ +# Copilot Instructions for ManagedCode.Storage + +## Overview + +ManagedCode.Storage is a universal storage abstraction library that provides a consistent interface for working with multiple cloud blob storage providers including Azure Blob Storage, AWS S3, Google Cloud Storage, and local file system. The library aims to simplify development by providing a single API for all storage operations. + +## Project Structure + +- **ManagedCode.Storage.Core**: Core abstractions and interfaces (IStorage, BaseStorage, etc.) +- **Storages/**: Provider-specific implementations + - `ManagedCode.Storage.Azure`: Azure Blob Storage implementation + - `ManagedCode.Storage.Aws`: AWS S3 implementation + - `ManagedCode.Storage.Google`: Google Cloud Storage implementation + - `ManagedCode.Storage.FileSystem`: Local file system implementation + - `ManagedCode.Storage.Ftp`: FTP storage implementation + - `ManagedCode.Storage.Azure.DataLake`: Azure Data Lake implementation +- **Tests/**: Unit and integration tests +- **Integrations/**: Additional integrations (SignalR, Client/Server components) + +## Technical Context + +- **Target Framework**: .NET 9.0 +- **Language Version**: C# 13 +- **Architecture**: Provider pattern with unified interfaces +- **Key Features**: Async/await support, streaming operations, metadata handling, progress reporting + +## Development Guidelines + +### Code Style & Standards +- Use nullable reference types (enabled in project) +- Follow async/await patterns consistently +- Use ValueTask for performance-critical operations where appropriate +- Implement proper cancellation token support in all async methods +- Use ConfigureAwait(false) for library code +- Follow dependency injection patterns + +### Key Interfaces & Patterns +- `IStorage`: Main storage interface for blob operations +- `IStorageOptions`: Configuration options for storage providers +- `BaseStorage`: Base implementation with common functionality +- All operations should support progress reporting via `IProgress` +- Use `BlobMetadata` for storing blob metadata +- Support for streaming operations with `IStreamer` + +### Performance Considerations +- Implement efficient streaming for large files +- Use memory-efficient approaches for data transfer +- Cache metadata when appropriate +- Support parallel operations where beneficial +- Minimize allocations in hot paths + +### Testing Approach +- Unit tests for core logic +- Integration tests for provider implementations +- Use test fakes/mocks for external dependencies +- Test error scenarios and edge cases +- Validate async operation behavior + +### Provider Implementation Guidelines +When implementing new storage providers: +1. Inherit from `BaseStorage` class +2. Implement all required interface methods +3. Handle provider-specific errors appropriately +4. Support all metadata operations +5. Implement efficient streaming operations +6. Add comprehensive tests +7. Document provider-specific limitations or features + +### Error Handling +- Use appropriate exception types for different error scenarios +- Provide meaningful error messages +- Handle provider-specific errors and translate to common exceptions +- Support retry mechanisms where appropriate + +### Documentation +- Document public APIs with XML comments +- Include usage examples for complex operations +- Document provider-specific behavior differences +- Keep README.md updated with supported features + +## Common Tasks + +### Adding a New Storage Provider +1. Create new project in `Storages/` folder +2. Inherit from `BaseStorage` +3. Implement provider-specific operations +4. Add configuration options +5. Create comprehensive tests +6. Update solution file and documentation + +### Implementing New Features +1. Define interface changes in Core project +2. Update BaseStorage if needed +3. Implement in all relevant providers +4. Add tests for new functionality +5. Update documentation + +### Performance Optimization +- Profile critical paths +- Optimize memory allocations +- Improve streaming performance +- Cache frequently accessed data +- Use efficient data structures + +## Dependencies & Libraries +- Provider-specific SDKs (Azure.Storage.Blobs, AWS SDK, Google Cloud Storage) +- Microsoft.Extensions.* for dependency injection and configuration +- System.Text.Json for serialization +- Benchmarking tools for performance testing + +## Building & Testing +- Use `dotnet build` to build the solution +- Run `dotnet test` for unit tests +- Integration tests may require cloud provider credentials +- Use `dotnet pack` to create NuGet packages \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Core/IVfsEntry.cs b/ManagedCode.Storage.VirtualFileSystem/Core/IVfsEntry.cs new file mode 100644 index 0000000..697446b --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Core/IVfsEntry.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.VirtualFileSystem.Core; + +namespace ManagedCode.Storage.VirtualFileSystem.Core; + +/// +/// Base interface for virtual file system entries +/// +public interface IVfsEntry +{ + /// + /// Gets the path of this entry + /// + VfsPath Path { get; } + + /// + /// Gets the name of this entry + /// + string Name { get; } + + /// + /// Gets the type of this entry + /// + VfsEntryType Type { get; } + + /// + /// Gets when this entry was created + /// + DateTimeOffset CreatedOn { get; } + + /// + /// Gets when this entry was last modified + /// + DateTimeOffset LastModified { get; } + + /// + /// Checks if this entry exists + /// + /// Cancellation token + /// True if the entry exists + ValueTask ExistsAsync(CancellationToken cancellationToken = default); + + /// + /// Refreshes the entry information from storage + /// + /// Cancellation token + /// Task representing the async operation + Task RefreshAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the parent directory of this entry + /// + /// Cancellation token + /// The parent directory + ValueTask GetParentAsync(CancellationToken cancellationToken = default); +} + +/// +/// Type of virtual file system entry +/// +public enum VfsEntryType +{ + /// + /// A file entry + /// + File, + + /// + /// A directory entry + /// + Directory +} + +/// +/// Progress information for copy operations +/// +public class CopyProgress +{ + /// + /// Total number of bytes to copy + /// + public long TotalBytes { get; set; } + + /// + /// Number of bytes copied so far + /// + public long CopiedBytes { get; set; } + + /// + /// Total number of files to copy + /// + public int TotalFiles { get; set; } + + /// + /// Number of files copied so far + /// + public int CopiedFiles { get; set; } + + /// + /// Current file being copied + /// + public string? CurrentFile { get; set; } + + /// + /// Percentage completed (0-100) + /// + public double PercentageComplete => TotalBytes > 0 ? (double)CopiedBytes / TotalBytes * 100 : 0; +} + +/// +/// Result of a delete directory operation +/// +public class DeleteDirectoryResult +{ + /// + /// Whether the operation was successful + /// + public bool Success { get; set; } + + /// + /// Number of files deleted + /// + public int FilesDeleted { get; set; } + + /// + /// Number of directories deleted + /// + public int DirectoriesDeleted { get; set; } + + /// + /// List of errors encountered during deletion + /// + public List Errors { get; set; } = new(); +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualDirectory.cs b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualDirectory.cs new file mode 100644 index 0000000..7793860 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualDirectory.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Core; + +/// +/// Represents a directory in the virtual filesystem +/// +public interface IVirtualDirectory : IVfsEntry +{ + /// + /// Lists files in this directory with pagination and pattern matching + /// + /// Search pattern for filtering + /// Whether to search recursively + /// Page size for pagination + /// Cancellation token + /// Async enumerable of files + IAsyncEnumerable GetFilesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + CancellationToken cancellationToken = default); + + /// + /// Lists subdirectories with pagination + /// + /// Search pattern for filtering + /// Whether to search recursively + /// Page size for pagination + /// Cancellation token + /// Async enumerable of directories + IAsyncEnumerable GetDirectoriesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + CancellationToken cancellationToken = default); + + /// + /// Lists all entries (files and directories) in this directory + /// + /// Search pattern for filtering + /// Whether to search recursively + /// Page size for pagination + /// Cancellation token + /// Async enumerable of entries + IAsyncEnumerable GetEntriesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + CancellationToken cancellationToken = default); + + /// + /// Creates a file in this directory + /// + /// File name + /// File creation options + /// Cancellation token + /// The created file + ValueTask CreateFileAsync( + string name, + CreateFileOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Creates a subdirectory + /// + /// Directory name + /// Cancellation token + /// The created directory + ValueTask CreateDirectoryAsync( + string name, + CancellationToken cancellationToken = default); + + /// + /// Gets statistics for this directory + /// + /// Whether to calculate recursively + /// Cancellation token + /// Directory statistics + Task GetStatsAsync( + bool recursive = true, + CancellationToken cancellationToken = default); + + /// + /// Deletes this directory + /// + /// Whether to delete recursively + /// Cancellation token + /// Delete operation result + Task DeleteAsync( + bool recursive = false, + CancellationToken cancellationToken = default); +} + +/// +/// Statistics for a directory +/// +public class DirectoryStats +{ + /// + /// Number of files in the directory + /// + public int FileCount { get; init; } + + /// + /// Number of subdirectories + /// + public int DirectoryCount { get; init; } + + /// + /// Total size of all files in bytes + /// + public long TotalSize { get; init; } + + /// + /// File count by extension + /// + public Dictionary FilesByExtension { get; init; } = new(); + + /// + /// The largest file in the directory + /// + public IVirtualFile? LargestFile { get; init; } + + /// + /// Oldest modification date + /// + public DateTimeOffset? OldestModified { get; init; } + + /// + /// Newest modification date + /// + public DateTimeOffset? NewestModified { get; init; } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFile.cs b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFile.cs new file mode 100644 index 0000000..0811c53 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFile.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Core; + +/// +/// Represents a file in the virtual filesystem +/// +public interface IVirtualFile : IVfsEntry +{ + /// + /// Gets the file size in bytes + /// + long Size { get; } + + /// + /// Gets the MIME content type + /// + string? ContentType { get; } + + /// + /// Gets the ETag for concurrency control + /// + string? ETag { get; } + + /// + /// Gets the content hash (MD5 or SHA256) + /// + string? ContentHash { get; } + + // Streaming Operations + + /// + /// Opens a stream for reading the file + /// + /// Streaming options + /// Cancellation token + /// A readable stream + Task OpenReadAsync( + StreamOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Opens a stream for writing to the file + /// + /// Write options including ETag check + /// Cancellation token + /// A writable stream + Task OpenWriteAsync( + WriteOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Reads a specific range of bytes from the file + /// + /// Starting offset + /// Number of bytes to read + /// Cancellation token + /// The requested bytes + ValueTask ReadRangeAsync( + long offset, + int count, + CancellationToken cancellationToken = default); + + // Convenience Methods + + /// + /// Reads the entire file as bytes (use only for small files!) + /// + /// Cancellation token + /// File contents as bytes + Task ReadAllBytesAsync(CancellationToken cancellationToken = default); + + /// + /// Reads the file as text + /// + /// Text encoding (defaults to UTF-8) + /// Cancellation token + /// File contents as text + Task ReadAllTextAsync( + Encoding? encoding = null, + CancellationToken cancellationToken = default); + + /// + /// Writes bytes to the file with optional ETag check + /// + /// Bytes to write + /// Write options + /// Cancellation token + /// Task representing the async operation + Task WriteAllBytesAsync( + byte[] bytes, + WriteOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Writes text to the file with optional ETag check + /// + /// Text to write + /// Text encoding (defaults to UTF-8) + /// Write options + /// Cancellation token + /// Task representing the async operation + Task WriteAllTextAsync( + string text, + Encoding? encoding = null, + WriteOptions? options = null, + CancellationToken cancellationToken = default); + + // Metadata Operations + + /// + /// Gets all metadata for the file (cached) + /// + /// Cancellation token + /// Metadata dictionary + ValueTask> GetMetadataAsync( + CancellationToken cancellationToken = default); + + /// + /// Sets metadata for the file with ETag check + /// + /// Metadata to set + /// Expected ETag for concurrency control + /// Cancellation token + /// Task representing the async operation + Task SetMetadataAsync( + IDictionary metadata, + string? expectedETag = null, + CancellationToken cancellationToken = default); + + // Large File Support + + /// + /// Starts a multipart upload for large files + /// + /// Cancellation token + /// Multipart upload handle + Task StartMultipartUploadAsync( + CancellationToken cancellationToken = default); + + /// + /// Deletes this file + /// + /// Cancellation token + /// True if the file was deleted + Task DeleteAsync(CancellationToken cancellationToken = default); +} + +/// +/// Represents a multipart upload for large files +/// +public interface IMultipartUpload : IAsyncDisposable +{ + /// + /// Upload a part of the file + /// + /// Part number (1-based) + /// Part data stream + /// Cancellation token + /// Upload part information + Task UploadPartAsync( + int partNumber, + Stream data, + CancellationToken cancellationToken = default); + + /// + /// Completes the multipart upload + /// + /// List of uploaded parts + /// Cancellation token + /// Task representing the async operation + Task CompleteAsync( + IList parts, + CancellationToken cancellationToken = default); + + /// + /// Aborts the multipart upload + /// + /// Cancellation token + /// Task representing the async operation + Task AbortAsync(CancellationToken cancellationToken = default); +} + +/// +/// Information about an uploaded part +/// +public class UploadPart +{ + /// + /// Part number (1-based) + /// + public int PartNumber { get; set; } + + /// + /// ETag of the uploaded part + /// + public string ETag { get; set; } = null!; + + /// + /// Size of the part in bytes + /// + public long Size { get; set; } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFileSystem.cs b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFileSystem.cs new file mode 100644 index 0000000..b5177ee --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Core/IVirtualFileSystem.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Core; + +/// +/// Main virtual filesystem interface providing filesystem abstraction over blob storage +/// +public interface IVirtualFileSystem : IAsyncDisposable +{ + /// + /// Gets the underlying storage provider + /// + IStorage Storage { get; } + + /// + /// Gets the container name in blob storage + /// + string ContainerName { get; } + + /// + /// Gets the configuration options for this VFS instance + /// + VfsOptions Options { get; } + + // File Operations - ValueTask for cache-friendly operations + + /// + /// Gets or creates a file reference (doesn't create actual blob until write) + /// + /// File path + /// Cancellation token + /// Virtual file instance + ValueTask GetFileAsync(VfsPath path, CancellationToken cancellationToken = default); + + /// + /// Checks if a file exists (often cached for performance) + /// + /// File path + /// Cancellation token + /// True if the file exists + ValueTask FileExistsAsync(VfsPath path, CancellationToken cancellationToken = default); + + /// + /// Deletes a file + /// + /// File path + /// Cancellation token + /// True if the file was deleted + ValueTask DeleteFileAsync(VfsPath path, CancellationToken cancellationToken = default); + + // Directory Operations + + /// + /// Gets or creates a directory reference (virtual - no actual blob created) + /// + /// Directory path + /// Cancellation token + /// Virtual directory instance + ValueTask GetDirectoryAsync(VfsPath path, CancellationToken cancellationToken = default); + + /// + /// Checks if a directory exists (has any blobs with the path prefix) + /// + /// Directory path + /// Cancellation token + /// True if the directory exists + ValueTask DirectoryExistsAsync(VfsPath path, CancellationToken cancellationToken = default); + + /// + /// Deletes a directory and optionally all its contents + /// + /// Directory path + /// Whether to delete recursively + /// Cancellation token + /// Delete operation result + Task DeleteDirectoryAsync( + VfsPath path, + bool recursive = false, + CancellationToken cancellationToken = default); + + // Common Operations - Task for always-async operations + + /// + /// Moves/renames a file or directory + /// + /// Source path + /// Destination path + /// Move options + /// Cancellation token + /// Task representing the async operation + Task MoveAsync( + VfsPath source, + VfsPath destination, + MoveOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Copies a file or directory + /// + /// Source path + /// Destination path + /// Copy options + /// Progress reporting + /// Cancellation token + /// Task representing the async operation + Task CopyAsync( + VfsPath source, + VfsPath destination, + CopyOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + /// + /// Gets entry (file or directory) information + /// + /// Entry path + /// Cancellation token + /// Entry information or null if not found + ValueTask GetEntryAsync(VfsPath path, CancellationToken cancellationToken = default); + + /// + /// Lists directory contents with pagination + /// + /// Directory path + /// Listing options + /// Cancellation token + /// Async enumerable of entries + IAsyncEnumerable ListAsync( + VfsPath path, + ListOptions? options = null, + CancellationToken cancellationToken = default); +} + +/// +/// Manager for multiple virtual file system mounts +/// +public interface IVirtualFileSystemManager : IAsyncDisposable +{ + /// + /// Mounts a storage provider at the specified mount point + /// + /// Mount point path + /// Storage provider + /// VFS options + /// Cancellation token + /// Task representing the async operation + Task MountAsync( + string mountPoint, + IStorage storage, + VfsOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Unmounts a storage provider from the specified mount point + /// + /// Mount point path + /// Cancellation token + /// Task representing the async operation + Task UnmountAsync(string mountPoint, CancellationToken cancellationToken = default); + + /// + /// Gets the VFS instance for a mount point + /// + /// Mount point path + /// VFS instance + IVirtualFileSystem GetMount(string mountPoint); + + /// + /// Gets all current mounts + /// + /// Dictionary of mount points and their VFS instances + IReadOnlyDictionary GetMounts(); + + /// + /// Resolves a path to a mount point and relative path + /// + /// Full path + /// Mount point and relative path + (string MountPoint, VfsPath RelativePath) ResolvePath(string path); +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Core/VfsPath.cs b/ManagedCode.Storage.VirtualFileSystem/Core/VfsPath.cs new file mode 100644 index 0000000..ffc4c75 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Core/VfsPath.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace ManagedCode.Storage.VirtualFileSystem.Core; + +/// +/// Normalized path representation for virtual filesystem +/// +public readonly struct VfsPath : IEquatable +{ + private readonly string _normalized; + + /// + /// Initializes a new instance of VfsPath with the specified path + /// + /// The path to normalize + /// Thrown when path is null or whitespace + public VfsPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path cannot be null or empty", nameof(path)); + + _normalized = NormalizePath(path); + } + + /// + /// Gets the normalized path value + /// + public string Value => _normalized; + + /// + /// Gets a value indicating whether this path represents the root directory + /// + public bool IsRoot => _normalized == "/"; + + /// + /// Gets a value indicating whether this path represents a directory (no file extension) + /// + public bool IsDirectory => !Path.HasExtension(_normalized); + + /// + /// Gets the parent directory path + /// + /// The parent directory path, or root if this is already root + public VfsPath GetParent() + { + if (IsRoot) return this; + var lastSlash = _normalized.LastIndexOf('/'); + return new VfsPath(lastSlash == 0 ? "/" : _normalized[..lastSlash]); + } + + /// + /// Combines this path with a child name + /// + /// The child name to combine + /// A new VfsPath representing the combined path + public VfsPath Combine(string name) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name cannot be null or empty", nameof(name)); + + return new VfsPath(_normalized == "/" ? "/" + name : _normalized + "/" + name); + } + + /// + /// Gets the file name portion of the path + /// + /// The file name + public string GetFileName() => Path.GetFileName(_normalized); + + /// + /// Gets the file name without extension + /// + /// The file name without extension + public string GetFileNameWithoutExtension() => Path.GetFileNameWithoutExtension(_normalized); + + /// + /// Gets the file extension + /// + /// The file extension including the leading dot + public string GetExtension() => Path.GetExtension(_normalized); + + /// + /// Converts the path to a blob key for storage operations + /// + /// The blob key (path without leading slash) + public string ToBlobKey() + { + return _normalized.Length > 1 ? _normalized[1..] : ""; + } + + /// + /// Normalize path to canonical form + /// + private static string NormalizePath(string path) + { + // 1. Replace backslashes with forward slashes + path = path.Replace('\\', '/'); + + // 2. Collapse multiple slashes + while (path.Contains("//")) + path = path.Replace("//", "/"); + + // 3. Remove trailing slash except for root + if (path.Length > 1 && path.EndsWith('/')) + path = path.TrimEnd('/'); + + // 4. Ensure absolute path + if (!path.StartsWith('/')) + path = '/' + path; + + // 5. Resolve . and .. + var segments = new List(); + foreach (var segment in path.Split('/')) + { + if (segment == "." || string.IsNullOrEmpty(segment)) + continue; + if (segment == "..") + { + if (segments.Count > 0) + segments.RemoveAt(segments.Count - 1); + } + else + { + segments.Add(segment); + } + } + + return "/" + string.Join("/", segments); + } + + /// + /// Implicit conversion from string to VfsPath + /// + public static implicit operator VfsPath(string path) => new(path); + + /// + /// Implicit conversion from VfsPath to string + /// + public static implicit operator string(VfsPath path) => path._normalized; + + /// + /// Returns the normalized path + /// + public override string ToString() => _normalized; + + /// + /// Returns the hash code for this path + /// + public override int GetHashCode() => _normalized.GetHashCode(StringComparison.Ordinal); + + /// + /// Determines whether this path equals another VfsPath + /// + public bool Equals(VfsPath other) => _normalized == other._normalized; + + /// + /// Determines whether this path equals another object + /// + public override bool Equals(object? obj) => obj is VfsPath other && Equals(other); + + /// + /// Equality operator + /// + public static bool operator ==(VfsPath left, VfsPath right) => left.Equals(right); + + /// + /// Inequality operator + /// + public static bool operator !=(VfsPath left, VfsPath right) => !left.Equals(right); +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Exceptions/VfsExceptions.cs b/ManagedCode.Storage.VirtualFileSystem/Exceptions/VfsExceptions.cs new file mode 100644 index 0000000..c1740bf --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Exceptions/VfsExceptions.cs @@ -0,0 +1,223 @@ +using System; +using ManagedCode.Storage.VirtualFileSystem.Core; + +namespace ManagedCode.Storage.VirtualFileSystem.Exceptions; + +/// +/// Base exception for virtual file system operations +/// +public abstract class VfsException : Exception +{ + /// + /// Initializes a new instance of VfsException + /// + protected VfsException() + { + } + + /// + /// Initializes a new instance of VfsException with the specified message + /// + /// Error message + protected VfsException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of VfsException with the specified message and inner exception + /// + /// Error message + /// Inner exception + protected VfsException(string message, Exception innerException) : base(message, innerException) + { + } +} + +/// +/// Exception thrown when a concurrent modification is detected +/// +public class VfsConcurrencyException : VfsException +{ + /// + /// Initializes a new instance of VfsConcurrencyException + /// + /// Error message + /// Path of the file that had concurrent modification + /// Expected ETag + /// Actual ETag + public VfsConcurrencyException(string message, VfsPath path, string? expectedETag, string? actualETag) + : base(message) + { + Path = path; + ExpectedETag = expectedETag; + ActualETag = actualETag; + } + + /// + /// Gets the path of the file that had concurrent modification + /// + public VfsPath Path { get; } + + /// + /// Gets the expected ETag + /// + public string? ExpectedETag { get; } + + /// + /// Gets the actual ETag + /// + public string? ActualETag { get; } +} + +/// +/// Exception thrown when a file or directory is not found +/// +public class VfsNotFoundException : VfsException +{ + /// + /// Initializes a new instance of VfsNotFoundException + /// + /// Path that was not found + public VfsNotFoundException(VfsPath path) + : base($"Path not found: {path}") + { + Path = path; + } + + /// + /// Initializes a new instance of VfsNotFoundException + /// + /// Path that was not found + /// Custom error message + public VfsNotFoundException(VfsPath path, string message) + : base(message) + { + Path = path; + } + + /// + /// Gets the path that was not found + /// + public VfsPath Path { get; } +} + +/// +/// Exception thrown when a file or directory already exists and overwrite is not allowed +/// +public class VfsAlreadyExistsException : VfsException +{ + /// + /// Initializes a new instance of VfsAlreadyExistsException + /// + /// Path that already exists + public VfsAlreadyExistsException(VfsPath path) + : base($"Path already exists: {path}") + { + Path = path; + } + + /// + /// Initializes a new instance of VfsAlreadyExistsException + /// + /// Path that already exists + /// Custom error message + public VfsAlreadyExistsException(VfsPath path, string message) + : base(message) + { + Path = path; + } + + /// + /// Gets the path that already exists + /// + public VfsPath Path { get; } +} + +/// +/// Exception thrown when an invalid path is provided +/// +public class VfsInvalidPathException : VfsException +{ + /// + /// Initializes a new instance of VfsInvalidPathException + /// + /// Invalid path + /// Reason why the path is invalid + public VfsInvalidPathException(string path, string reason) + : base($"Invalid path '{path}': {reason}") + { + InvalidPath = path; + Reason = reason; + } + + /// + /// Gets the invalid path + /// + public string InvalidPath { get; } + + /// + /// Gets the reason why the path is invalid + /// + public string Reason { get; } +} + +/// +/// Exception thrown when an operation is not supported +/// +public class VfsNotSupportedException : VfsException +{ + /// + /// Initializes a new instance of VfsNotSupportedException + /// + /// Operation that is not supported + public VfsNotSupportedException(string operation) + : base($"Operation not supported: {operation}") + { + Operation = operation; + } + + /// + /// Initializes a new instance of VfsNotSupportedException + /// + /// Operation that is not supported + /// Reason why the operation is not supported + public VfsNotSupportedException(string operation, string reason) + : base($"Operation not supported: {operation}. {reason}") + { + Operation = operation; + Reason = reason; + } + + /// + /// Gets the operation that is not supported + /// + public string Operation { get; } + + /// + /// Gets the reason why the operation is not supported + /// + public string? Reason { get; } +} + +/// +/// General VFS operation exception +/// +public class VfsOperationException : VfsException +{ + /// + /// Initializes a new instance of VfsOperationException + /// + /// Error message + public VfsOperationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of VfsOperationException + /// + /// Error message + /// Inner exception + public VfsOperationException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs b/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..142e2e6 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Implementations; +using ManagedCode.Storage.VirtualFileSystem.Metadata; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Extensions; + +/// +/// Extension methods for registering Virtual File System services +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Virtual File System services to the service collection + /// + /// The service collection + /// Optional configuration action for VFS options + /// The service collection for chaining + public static IServiceCollection AddVirtualFileSystem( + this IServiceCollection services, + Action? configureOptions = null) + { + // Configure options + if (configureOptions != null) + { + services.Configure(configureOptions); + } + else + { + services.Configure(_ => { }); + } + + // Register core services + services.TryAddSingleton(); + + // Register VFS services + services.TryAddScoped(); + services.TryAddSingleton(); + + // Register metadata manager (this will be overridden by storage-specific registrations) + services.TryAddScoped(); + + return services; + } + + /// + /// Adds Virtual File System with a specific storage provider + /// + /// The service collection + /// The storage provider + /// Optional configuration action for VFS options + /// The service collection for chaining + public static IServiceCollection AddVirtualFileSystem( + this IServiceCollection services, + IStorage storage, + Action? configureOptions = null) + { + services.AddSingleton(storage); + return services.AddVirtualFileSystem(configureOptions); + } + + /// + /// Adds Virtual File System with a factory for creating storage providers + /// + /// The service collection + /// Factory function for creating storage providers + /// Optional configuration action for VFS options + /// The service collection for chaining + public static IServiceCollection AddVirtualFileSystem( + this IServiceCollection services, + Func storageFactory, + Action? configureOptions = null) + { + services.AddScoped(storageFactory); + return services.AddVirtualFileSystem(configureOptions); + } +} + +/// +/// Default metadata manager implementation +/// +internal class DefaultMetadataManager : BaseMetadataManager +{ + private readonly IStorage _storage; + private readonly ILogger _logger; + + protected override string MetadataPrefix => "x-vfs-"; + + public DefaultMetadataManager(IStorage storage, ILogger logger) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async Task SetVfsMetadataAsync( + string blobName, + VfsMetadata metadata, + IDictionary? customMetadata = null, + string? expectedETag = null, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Setting VFS metadata for: {BlobName}", blobName); + + var metadataDict = BuildMetadataDictionary(metadata, customMetadata); + + // Use the storage provider's metadata setting capability + // Note: This is a simplified implementation. Real implementation would depend on the storage provider + try + { + var blobMetadata = await _storage.GetBlobMetadataAsync(blobName, cancellationToken); + if (blobMetadata.IsSuccess && blobMetadata.Value != null) + { + // Update existing metadata + var existingMetadata = blobMetadata.Value.Metadata ?? new Dictionary(); + foreach (var kvp in metadataDict) + { + existingMetadata[kvp.Key] = kvp.Value; + } + + // Note: Most storage providers don't have a direct "set metadata" operation + // This would typically require re-uploading the blob with new metadata + _logger.LogWarning("Metadata update not fully implemented for this storage provider"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting VFS metadata for: {BlobName}", blobName); + throw; + } + } + + public override async Task GetVfsMetadataAsync( + string blobName, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting VFS metadata for: {BlobName}", blobName); + + try + { + var blobMetadata = await _storage.GetBlobMetadataAsync(blobName, cancellationToken); + if (!blobMetadata.IsSuccess || blobMetadata.Value?.Metadata == null) + { + return null; + } + + return ParseVfsMetadata(blobMetadata.Value.Metadata); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting VFS metadata for: {BlobName}", blobName); + return null; + } + } + + public override async Task> GetCustomMetadataAsync( + string blobName, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting custom metadata for: {BlobName}", blobName); + + try + { + var blobMetadata = await _storage.GetBlobMetadataAsync(blobName, cancellationToken); + if (!blobMetadata.IsSuccess || blobMetadata.Value?.Metadata == null) + { + return new Dictionary(); + } + + return ExtractCustomMetadata(blobMetadata.Value.Metadata); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting custom metadata for: {BlobName}", blobName); + return new Dictionary(); + } + } + + public override async Task GetBlobInfoAsync( + string blobName, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting blob info for: {BlobName}", blobName); + + try + { + var result = await _storage.GetBlobMetadataAsync(blobName, cancellationToken); + return result.IsSuccess ? result.Value : null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Blob not found or error getting blob info: {BlobName}", blobName); + return null; + } + } +} + +/// +/// Implementation of Virtual File System Manager +/// +internal class VirtualFileSystemManager : IVirtualFileSystemManager +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly Dictionary _mounts = new(); + private bool _disposed; + + public VirtualFileSystemManager( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task MountAsync( + string mountPoint, + IStorage storage, + VfsOptions? options = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(mountPoint)) + throw new ArgumentException("Mount point cannot be null or empty", nameof(mountPoint)); + + if (storage == null) + throw new ArgumentNullException(nameof(storage)); + + _logger.LogDebug("Mounting storage at: {MountPoint}", mountPoint); + + // Normalize mount point + mountPoint = mountPoint.TrimEnd('/'); + if (!mountPoint.StartsWith('/')) + mountPoint = '/' + mountPoint; + + // Create VFS instance + var cache = _serviceProvider.GetRequiredService(); + var loggerFactory = _serviceProvider.GetRequiredService(); + var metadataManager = new DefaultMetadataManager(storage, loggerFactory.CreateLogger()); + + var vfsOptions = Microsoft.Extensions.Options.Options.Create(options ?? new VfsOptions()); + var vfsLogger = loggerFactory.CreateLogger(); + + var vfs = new Implementations.VirtualFileSystem(storage, metadataManager, vfsOptions, cache, vfsLogger); + + _mounts[mountPoint] = vfs; + + _logger.LogInformation("Storage mounted successfully at: {MountPoint}", mountPoint); + } + + public async Task UnmountAsync(string mountPoint, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(mountPoint)) + throw new ArgumentException("Mount point cannot be null or empty", nameof(mountPoint)); + + // Normalize mount point + mountPoint = mountPoint.TrimEnd('/'); + if (!mountPoint.StartsWith('/')) + mountPoint = '/' + mountPoint; + + _logger.LogDebug("Unmounting storage from: {MountPoint}", mountPoint); + + if (_mounts.TryGetValue(mountPoint, out var vfs)) + { + await vfs.DisposeAsync(); + _mounts.Remove(mountPoint); + _logger.LogInformation("Storage unmounted from: {MountPoint}", mountPoint); + } + else + { + _logger.LogWarning("No mount found at: {MountPoint}", mountPoint); + } + } + + public IVirtualFileSystem GetMount(string mountPoint) + { + if (string.IsNullOrWhiteSpace(mountPoint)) + throw new ArgumentException("Mount point cannot be null or empty", nameof(mountPoint)); + + // Normalize mount point + mountPoint = mountPoint.TrimEnd('/'); + if (!mountPoint.StartsWith('/')) + mountPoint = '/' + mountPoint; + + if (_mounts.TryGetValue(mountPoint, out var vfs)) + { + return vfs; + } + + throw new ArgumentException($"No mount found at: {mountPoint}", nameof(mountPoint)); + } + + public IReadOnlyDictionary GetMounts() + { + return new ReadOnlyDictionary(_mounts); + } + + public (string MountPoint, VfsPath RelativePath) ResolvePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path cannot be null or empty", nameof(path)); + + // Normalize path + if (!path.StartsWith('/')) + path = '/' + path; + + // Find the longest matching mount point + var bestMatch = ""; + foreach (var mountPoint in _mounts.Keys.OrderByDescending(mp => mp.Length)) + { + if (path.StartsWith(mountPoint + "/") || path == mountPoint) + { + bestMatch = mountPoint; + break; + } + } + + if (string.IsNullOrEmpty(bestMatch)) + { + throw new ArgumentException($"No mount point found for path: {path}", nameof(path)); + } + + var relativePath = path == bestMatch ? "/" : path[bestMatch.Length..]; + return (bestMatch, new VfsPath(relativePath)); + } + + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + _logger.LogDebug("Disposing VirtualFileSystemManager"); + + foreach (var vfs in _mounts.Values) + { + await vfs.DisposeAsync(); + } + + _mounts.Clear(); + _disposed = true; + } + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs new file mode 100644 index 0000000..57ab7d8 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualDirectory.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Metadata; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Implementations; + +/// +/// Implementation of a virtual directory +/// +public class VirtualDirectory : IVirtualDirectory +{ + private readonly IVirtualFileSystem _vfs; + private readonly IMetadataManager _metadataManager; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly VfsPath _path; + + private VfsMetadata? _vfsMetadata; + private bool _metadataLoaded; + + /// + /// Initializes a new instance of VirtualDirectory + /// + public VirtualDirectory( + IVirtualFileSystem vfs, + IMetadataManager metadataManager, + IMemoryCache cache, + ILogger logger, + VfsPath path) + { + _vfs = vfs ?? throw new ArgumentNullException(nameof(vfs)); + _metadataManager = metadataManager ?? throw new ArgumentNullException(nameof(metadataManager)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _path = path; + } + + /// + public VfsPath Path => _path; + + /// + public string Name => _path.IsRoot ? "/" : _path.GetFileName(); + + /// + public VfsEntryType Type => VfsEntryType.Directory; + + /// + public DateTimeOffset CreatedOn => _vfsMetadata?.Created ?? DateTimeOffset.MinValue; + + /// + public DateTimeOffset LastModified => _vfsMetadata?.Modified ?? DateTimeOffset.MinValue; + + /// + public async ValueTask ExistsAsync(CancellationToken cancellationToken = default) + { + return await _vfs.DirectoryExistsAsync(_path, cancellationToken); + } + + /// + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + _logger.LogDebug("Refreshing directory metadata: {Path}", _path); + + // For virtual directories, we might not have explicit metadata unless using a directory strategy + // that creates marker files + if (_vfs.Options.DirectoryStrategy != DirectoryStrategy.Virtual) + { + var markerKey = GetDirectoryMarkerKey(); + _vfsMetadata = await _metadataManager.GetVfsMetadataAsync(markerKey, cancellationToken); + } + + _metadataLoaded = true; + } + + /// + public async ValueTask GetParentAsync(CancellationToken cancellationToken = default) + { + var parentPath = _path.GetParent(); + return await _vfs.GetDirectoryAsync(parentPath, cancellationToken); + } + + /// + public async IAsyncEnumerable GetFilesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting files: {Path}, recursive: {Recursive}", _path, recursive); + + await foreach (var entry in GetEntriesInternalAsync(pattern, recursive, pageSize, true, false, cancellationToken)) + { + if (entry is IVirtualFile file) + { + yield return file; + } + } + } + + /// + public async IAsyncEnumerable GetDirectoriesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting directories: {Path}, recursive: {Recursive}", _path, recursive); + + await foreach (var entry in GetEntriesInternalAsync(pattern, recursive, pageSize, false, true, cancellationToken)) + { + if (entry is IVirtualDirectory directory) + { + yield return directory; + } + } + } + + /// + public async IAsyncEnumerable GetEntriesAsync( + SearchPattern? pattern = null, + bool recursive = false, + int pageSize = 100, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting entries: {Path}, recursive: {Recursive}", _path, recursive); + + await foreach (var entry in GetEntriesInternalAsync(pattern, recursive, pageSize, true, true, cancellationToken)) + { + yield return entry; + } + } + + private async IAsyncEnumerable GetEntriesInternalAsync( + SearchPattern? pattern, + bool recursive, + int pageSize, + bool includeFiles, + bool includeDirectories, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var prefix = _path.ToBlobKey(); + if (!string.IsNullOrEmpty(prefix) && !prefix.EndsWith('/')) + prefix += "/"; + + var directories = new HashSet(); + + await foreach (var blob in _vfs.Storage.GetBlobMetadataListAsync(prefix, cancellationToken)) + { + var relativePath = blob.FullName.Length > prefix.Length ? + blob.FullName[prefix.Length..] : ""; + + if (string.IsNullOrEmpty(relativePath)) + continue; + + if (!recursive) + { + // For non-recursive, check if this blob is in a subdirectory + var slashIndex = relativePath.IndexOf('/'); + if (slashIndex > 0) + { + // This is in a subdirectory + var dirName = relativePath[..slashIndex]; + if (includeDirectories && directories.Add(dirName)) + { + if (pattern == null || pattern.IsMatch(dirName)) + { + var dirPath = _path.Combine(dirName); + yield return new VirtualDirectory(_vfs, _metadataManager, _cache, _logger, dirPath); + } + } + continue; // Skip the file itself for non-recursive + } + } + + // Handle the file + if (includeFiles) + { + var fileName = System.IO.Path.GetFileName(blob.FullName); + if (pattern == null || pattern.IsMatch(fileName)) + { + var filePath = new VfsPath("/" + blob.FullName); + var file = new VirtualFile(_vfs, _metadataManager, _cache, _logger, filePath); + yield return file; + } + } + + // In recursive mode, also track intermediate directories + if (recursive && includeDirectories) + { + var pathParts = relativePath.Split('/'); + var currentPath = ""; + + for (int i = 0; i < pathParts.Length - 1; i++) // Exclude the file name itself + { + if (i > 0) currentPath += "/"; + currentPath += pathParts[i]; + + if (directories.Add(currentPath)) + { + if (pattern == null || pattern.IsMatch(pathParts[i])) + { + var dirPath = _path.Combine(currentPath); + yield return new VirtualDirectory(_vfs, _metadataManager, _cache, _logger, dirPath); + } + } + } + } + } + } + + /// + public async ValueTask CreateFileAsync( + string name, + CreateFileOptions? options = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("File name cannot be null or empty", nameof(name)); + + options ??= new CreateFileOptions(); + + _logger.LogDebug("Creating file: {Path}/{Name}", _path, name); + + var filePath = _path.Combine(name); + var file = await _vfs.GetFileAsync(filePath, cancellationToken); + + if (await file.ExistsAsync(cancellationToken) && !options.Overwrite) + { + throw new VfsAlreadyExistsException(filePath); + } + + // Create empty file with metadata + var writeOptions = new WriteOptions + { + ContentType = options.ContentType, + Metadata = options.Metadata, + Overwrite = options.Overwrite + }; + + await file.WriteAllBytesAsync(Array.Empty(), writeOptions, cancellationToken); + + return file; + } + + /// + public async ValueTask CreateDirectoryAsync( + string name, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Directory name cannot be null or empty", nameof(name)); + + _logger.LogDebug("Creating directory: {Path}/{Name}", _path, name); + + var dirPath = _path.Combine(name); + var directory = await _vfs.GetDirectoryAsync(dirPath, cancellationToken); + + // Depending on the directory strategy, we might need to create a marker + switch (_vfs.Options.DirectoryStrategy) + { + case DirectoryStrategy.ZeroByteMarker: + { + var markerKey = dirPath.ToBlobKey() + "/"; + var uploadOptions = new UploadOptions(markerKey) + { + MimeType = "application/x-directory" + }; + await _vfs.Storage.UploadAsync(Array.Empty(), uploadOptions, cancellationToken); + break; + } + case DirectoryStrategy.DotKeepFile: + { + var keepFile = dirPath.Combine(".keep"); + var file = await _vfs.GetFileAsync(keepFile, cancellationToken); + await file.WriteAllBytesAsync(Array.Empty(), cancellationToken: cancellationToken); + break; + } + case DirectoryStrategy.Virtual: + default: + // No action needed for virtual directories + break; + } + + return directory; + } + + /// + public async Task GetStatsAsync( + bool recursive = true, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Getting directory stats: {Path}, recursive: {Recursive}", _path, recursive); + + var fileCount = 0; + var directoryCount = 0; + var totalSize = 0L; + var filesByExtension = new Dictionary(); + IVirtualFile? largestFile = null; + DateTimeOffset? oldestModified = null; + DateTimeOffset? newestModified = null; + + await foreach (var entry in GetEntriesAsync(recursive: recursive, cancellationToken: cancellationToken)) + { + if (entry.Type == VfsEntryType.File && entry is IVirtualFile file) + { + fileCount++; + totalSize += file.Size; + + var extension = System.IO.Path.GetExtension(file.Name).ToLowerInvariant(); + if (string.IsNullOrEmpty(extension)) + extension = "(no extension)"; + + filesByExtension[extension] = filesByExtension.GetValueOrDefault(extension, 0) + 1; + + if (largestFile == null || file.Size > largestFile.Size) + { + largestFile = file; + } + + if (oldestModified == null || file.LastModified < oldestModified) + { + oldestModified = file.LastModified; + } + + if (newestModified == null || file.LastModified > newestModified) + { + newestModified = file.LastModified; + } + } + else if (entry.Type == VfsEntryType.Directory) + { + directoryCount++; + } + } + + return new DirectoryStats + { + FileCount = fileCount, + DirectoryCount = directoryCount, + TotalSize = totalSize, + FilesByExtension = filesByExtension, + LargestFile = largestFile, + OldestModified = oldestModified, + NewestModified = newestModified + }; + } + + /// + public async Task DeleteAsync( + bool recursive = false, + CancellationToken cancellationToken = default) + { + return await _vfs.DeleteDirectoryAsync(_path, recursive, cancellationToken); + } + + private string GetDirectoryMarkerKey() + { + return _vfs.Options.DirectoryStrategy switch + { + DirectoryStrategy.ZeroByteMarker => _path.ToBlobKey() + "/", + DirectoryStrategy.DotKeepFile => _path.Combine(".keep").ToBlobKey(), + _ => _path.ToBlobKey() + }; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs new file mode 100644 index 0000000..22a3fca --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Metadata; +using ManagedCode.Storage.VirtualFileSystem.Options; +using ManagedCode.Storage.VirtualFileSystem.Streaming; + +namespace ManagedCode.Storage.VirtualFileSystem.Implementations; + +/// +/// Implementation of a virtual file +/// +public class VirtualFile : IVirtualFile +{ + private readonly IVirtualFileSystem _vfs; + private readonly IMetadataManager _metadataManager; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly VfsPath _path; + + private BlobMetadata? _blobMetadata; + private VfsMetadata? _vfsMetadata; + private bool _metadataLoaded; + + /// + /// Initializes a new instance of VirtualFile + /// + public VirtualFile( + IVirtualFileSystem vfs, + IMetadataManager metadataManager, + IMemoryCache cache, + ILogger logger, + VfsPath path) + { + _vfs = vfs ?? throw new ArgumentNullException(nameof(vfs)); + _metadataManager = metadataManager ?? throw new ArgumentNullException(nameof(metadataManager)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _path = path; + } + + /// + public VfsPath Path => _path; + + /// + public string Name => _path.GetFileName(); + + /// + public VfsEntryType Type => VfsEntryType.File; + + /// + public DateTimeOffset CreatedOn => _vfsMetadata?.Created ?? _blobMetadata?.CreatedOn ?? DateTimeOffset.MinValue; + + /// + public DateTimeOffset LastModified => _vfsMetadata?.Modified ?? _blobMetadata?.LastModified ?? DateTimeOffset.MinValue; + + /// + public long Size => (long)(_blobMetadata?.Length ?? 0); + + /// + public string? ContentType => _blobMetadata?.MimeType; + + /// + public string? ETag { get; private set; } + + /// + public string? ContentHash { get; private set; } + + /// + public async ValueTask ExistsAsync(CancellationToken cancellationToken = default) + { + return await _vfs.FileExistsAsync(_path, cancellationToken); + } + + /// + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + _logger.LogDebug("Refreshing file metadata: {Path}", _path); + + _blobMetadata = await _metadataManager.GetBlobInfoAsync(_path.ToBlobKey(), cancellationToken); + _vfsMetadata = await _metadataManager.GetVfsMetadataAsync(_path.ToBlobKey(), cancellationToken); + _metadataLoaded = true; + + // Update derived properties + if (_blobMetadata != null) + { + ETag = _blobMetadata.Uri?.Query.Contains("sv=") == true ? + ExtractETagFromUri(_blobMetadata.Uri) : null; + } + + // Invalidate cache + if (_vfs.Options.EnableCache) + { + var cacheKey = $"file_metadata:{_vfs.ContainerName}:{_path}"; + _cache.Remove(cacheKey); + } + } + + /// + public async ValueTask GetParentAsync(CancellationToken cancellationToken = default) + { + var parentPath = _path.GetParent(); + return await _vfs.GetDirectoryAsync(parentPath, cancellationToken); + } + + /// + public async Task OpenReadAsync( + StreamOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new StreamOptions(); + + _logger.LogDebug("Opening read stream: {Path}", _path); + + await EnsureMetadataLoadedAsync(cancellationToken); + + if (_blobMetadata == null) + { + throw new VfsNotFoundException(_path); + } + + try + { + var result = await _vfs.Storage.GetStreamAsync(_path.ToBlobKey(), cancellationToken); + + if (!result.IsSuccess || result.Value == null) + { + throw new VfsOperationException($"Failed to open read stream for file: {_path}"); + } + + return result.Value; + } + catch (Exception ex) when (!(ex is VfsException)) + { + _logger.LogError(ex, "Error opening read stream: {Path}", _path); + throw new VfsOperationException($"Failed to open read stream for file: {_path}", ex); + } + } + + /// + public async Task OpenWriteAsync( + WriteOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new WriteOptions(); + + _logger.LogDebug("Opening write stream: {Path}", _path); + + if (!options.Overwrite && await ExistsAsync(cancellationToken)) + { + throw new VfsAlreadyExistsException(_path); + } + + if (!string.IsNullOrEmpty(options.ExpectedETag)) + { + await EnsureMetadataLoadedAsync(cancellationToken); + if (ETag != options.ExpectedETag) + { + throw new VfsConcurrencyException( + "File was modified by another process", + _path, + options.ExpectedETag, + ETag); + } + } + + // For now, return a memory stream that will be uploaded when disposed + // This is a simplified implementation - real streaming would require provider-specific support + return new VfsWriteStream(_vfs.Storage, _path.ToBlobKey(), options, _cache, _vfs.Options, _logger); + } + + /// + public async ValueTask ReadRangeAsync( + long offset, + int count, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Reading range: {Path}, offset: {Offset}, count: {Count}", _path, offset, count); + + await using var stream = await OpenReadAsync( + new StreamOptions { RangeStart = offset, RangeEnd = offset + count - 1 }, + cancellationToken); + + var buffer = new byte[count]; + var bytesRead = await stream.ReadAsync(buffer, 0, count, cancellationToken); + + if (bytesRead < count) + { + Array.Resize(ref buffer, bytesRead); + } + + return buffer; + } + + /// + public async Task ReadAllBytesAsync(CancellationToken cancellationToken = default) + { + _logger.LogDebug("Reading all bytes: {Path}", _path); + + await using var stream = await OpenReadAsync(cancellationToken: cancellationToken); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, cancellationToken); + return memoryStream.ToArray(); + } + + /// + public async Task ReadAllTextAsync( + Encoding? encoding = null, + CancellationToken cancellationToken = default) + { + encoding ??= Encoding.UTF8; + + _logger.LogDebug("Reading all text: {Path}", _path); + + var bytes = await ReadAllBytesAsync(cancellationToken); + return encoding.GetString(bytes); + } + + /// + public async Task WriteAllBytesAsync( + byte[] bytes, + WriteOptions? options = null, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Writing all bytes: {Path}, size: {Size}", _path, bytes.Length); + + await using var stream = await OpenWriteAsync(options, cancellationToken); + await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken); + } + + /// + public async Task WriteAllTextAsync( + string text, + Encoding? encoding = null, + WriteOptions? options = null, + CancellationToken cancellationToken = default) + { + encoding ??= Encoding.UTF8; + + _logger.LogDebug("Writing all text: {Path}, length: {Length}", _path, text.Length); + + var bytes = encoding.GetBytes(text); + await WriteAllBytesAsync(bytes, options, cancellationToken); + } + + /// + public async ValueTask> GetMetadataAsync( + CancellationToken cancellationToken = default) + { + var cacheKey = $"file_metadata:{_vfs.ContainerName}:{_path}"; + + if (_vfs.Options.EnableCache && _cache.TryGetValue(cacheKey, out IReadOnlyDictionary cached)) + { + _logger.LogDebug("File metadata (cached): {Path}", _path); + return cached; + } + + var metadata = await _metadataManager.GetCustomMetadataAsync(_path.ToBlobKey(), cancellationToken); + + if (_vfs.Options.EnableCache) + { + _cache.Set(cacheKey, metadata, _vfs.Options.CacheTTL); + } + + _logger.LogDebug("File metadata: {Path}, count: {Count}", _path, metadata.Count); + return metadata; + } + + /// + public async Task SetMetadataAsync( + IDictionary metadata, + string? expectedETag = null, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Setting metadata: {Path}, count: {Count}", _path, metadata.Count); + + if (!string.IsNullOrEmpty(expectedETag)) + { + await EnsureMetadataLoadedAsync(cancellationToken); + if (ETag != expectedETag) + { + throw new VfsConcurrencyException( + "File was modified by another process", + _path, + expectedETag, + ETag); + } + } + + var vfsMetadata = _vfsMetadata ?? new VfsMetadata(); + vfsMetadata.Modified = DateTimeOffset.UtcNow; + + await _metadataManager.SetVfsMetadataAsync( + _path.ToBlobKey(), + vfsMetadata, + metadata, + expectedETag, + cancellationToken); + + // Invalidate cache + if (_vfs.Options.EnableCache) + { + var cacheKey = $"file_metadata:{_vfs.ContainerName}:{_path}"; + _cache.Remove(cacheKey); + } + } + + /// + public async Task StartMultipartUploadAsync(CancellationToken cancellationToken = default) + { + _logger.LogDebug("Starting multipart upload: {Path}", _path); + + // This is a simplified implementation - real multipart upload would depend on the storage provider + throw new VfsNotSupportedException("Multipart upload", "Not yet implemented in this version"); + } + + /// + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + return await _vfs.DeleteFileAsync(_path, cancellationToken); + } + + private async Task EnsureMetadataLoadedAsync(CancellationToken cancellationToken) + { + if (!_metadataLoaded) + { + await RefreshAsync(cancellationToken); + } + } + + private static string? ExtractETagFromUri(Uri uri) + { + // This is a simplified ETag extraction - real implementation would depend on the storage provider + return null; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs new file mode 100644 index 0000000..56f19f1 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.VirtualFileSystem.Core; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Metadata; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Implementations; + +/// +/// Main implementation of virtual file system +/// +public class VirtualFileSystem : IVirtualFileSystem +{ + private readonly IStorage _storage; + private readonly VfsOptions _options; + private readonly IMetadataManager _metadataManager; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private bool _disposed; + + /// + /// Initializes a new instance of VirtualFileSystem + /// + public VirtualFileSystem( + IStorage storage, + IMetadataManager metadataManager, + IOptions options, + IMemoryCache cache, + ILogger logger) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _metadataManager = metadataManager ?? throw new ArgumentNullException(nameof(metadataManager)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options.Value ?? throw new ArgumentNullException("options.Value"); + + ContainerName = _options.DefaultContainer; + } + + /// + public IStorage Storage => _storage; + + /// + public string ContainerName { get; } + + /// + public VfsOptions Options => _options; + + /// + public async ValueTask GetFileAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + _logger.LogDebug("Getting file: {Path}", path); + + return new VirtualFile(this, _metadataManager, _cache, _logger, path); + } + + /// + public async ValueTask FileExistsAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + var cacheKey = $"file_exists:{ContainerName}:{path}"; + + if (_options.EnableCache && _cache.TryGetValue(cacheKey, out bool cached)) + { + _logger.LogDebug("File exists check (cached): {Path} = {Exists}", path, cached); + return cached; + } + + try + { + var blobInfo = await _metadataManager.GetBlobInfoAsync(path.ToBlobKey(), cancellationToken); + var exists = blobInfo != null; + + if (_options.EnableCache) + { + _cache.Set(cacheKey, exists, _options.CacheTTL); + } + + _logger.LogDebug("File exists check: {Path} = {Exists}", path, exists); + return exists; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error checking file existence: {Path}", path); + return false; + } + } + + /// + public async ValueTask DeleteFileAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + _logger.LogDebug("Deleting file: {Path}", path); + + try + { + var result = await _storage.DeleteAsync(path.ToBlobKey(), cancellationToken); + + if (result.IsSuccess && result.Value) + { + // Invalidate cache + if (_options.EnableCache) + { + var cacheKey = $"file_exists:{ContainerName}:{path}"; + _cache.Remove(cacheKey); + } + + _logger.LogDebug("File deleted successfully: {Path}", path); + return true; + } + + _logger.LogDebug("File delete failed: {Path}", path); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file: {Path}", path); + throw new VfsOperationException($"Failed to delete file: {path}", ex); + } + } + + /// + public async ValueTask GetDirectoryAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + _logger.LogDebug("Getting directory: {Path}", path); + + return new VirtualDirectory(this, _metadataManager, _cache, _logger, path); + } + + /// + public async ValueTask DirectoryExistsAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + var cacheKey = $"dir_exists:{ContainerName}:{path}"; + + if (_options.EnableCache && _cache.TryGetValue(cacheKey, out bool cached)) + { + _logger.LogDebug("Directory exists check (cached): {Path} = {Exists}", path, cached); + return cached; + } + + try + { + var prefix = path.ToBlobKey(); + if (!string.IsNullOrEmpty(prefix) && !prefix.EndsWith('/')) + prefix += "/"; + + // Check if any blobs exist with this prefix + await foreach (var blob in _storage.GetBlobMetadataListAsync(prefix, cancellationToken)) + { + if (_options.EnableCache) + { + _cache.Set(cacheKey, true, _options.CacheTTL); + } + + _logger.LogDebug("Directory exists check: {Path} = true", path); + return true; + } + + if (_options.EnableCache) + { + _cache.Set(cacheKey, false, _options.CacheTTL); + } + + _logger.LogDebug("Directory exists check: {Path} = false", path); + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error checking directory existence: {Path}", path); + return false; + } + } + + /// + public async Task DeleteDirectoryAsync( + VfsPath path, + bool recursive = false, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + _logger.LogDebug("Deleting directory: {Path}, recursive: {Recursive}", path, recursive); + + var result = new DeleteDirectoryResult { Success = true }; + + try + { + var prefix = path.ToBlobKey(); + if (!string.IsNullOrEmpty(prefix) && !prefix.EndsWith('/')) + prefix += "/"; + + var filesToDelete = new List(); + + await foreach (var blob in _storage.GetBlobMetadataListAsync(prefix, cancellationToken)) + { + // For non-recursive, only delete direct children + if (!recursive) + { + var relativePath = blob.FullName[prefix.Length..]; + if (relativePath.Contains('/')) + { + // This is in a subdirectory, skip it + continue; + } + } + + filesToDelete.Add(blob.FullName); + } + + // Delete files + foreach (var fileName in filesToDelete) + { + try + { + var deleteResult = await _storage.DeleteAsync(fileName, cancellationToken); + if (deleteResult.IsSuccess && deleteResult.Value) + { + result.FilesDeleted++; + } + else + { + result.Errors.Add($"Failed to delete file: {fileName}"); + } + } + catch (Exception ex) + { + result.Errors.Add($"Error deleting file {fileName}: {ex.Message}"); + _logger.LogWarning(ex, "Error deleting file: {FileName}", fileName); + } + } + + // Invalidate cache + if (_options.EnableCache) + { + var cacheKey = $"dir_exists:{ContainerName}:{path}"; + _cache.Remove(cacheKey); + } + + result.Success = result.Errors.Count == 0; + _logger.LogDebug("Directory delete completed: {Path}, files deleted: {FilesDeleted}, errors: {ErrorCount}", + path, result.FilesDeleted, result.Errors.Count); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting directory: {Path}", path); + result.Success = false; + result.Errors.Add($"Unexpected error: {ex.Message}"); + return result; + } + } + + /// + public async Task MoveAsync( + VfsPath source, + VfsPath destination, + MoveOptions? options = null, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + options ??= new MoveOptions(); + + _logger.LogDebug("Moving: {Source} -> {Destination}", source, destination); + + // For now, implement as copy + delete + await CopyAsync(source, destination, new CopyOptions + { + Overwrite = options.Overwrite, + PreserveMetadata = options.PreserveMetadata + }, null, cancellationToken); + + // Delete source + if (await FileExistsAsync(source, cancellationToken)) + { + await DeleteFileAsync(source, cancellationToken); + } + else if (await DirectoryExistsAsync(source, cancellationToken)) + { + await DeleteDirectoryAsync(source, true, cancellationToken); + } + + _logger.LogDebug("Move completed: {Source} -> {Destination}", source, destination); + } + + /// + public async Task CopyAsync( + VfsPath source, + VfsPath destination, + CopyOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + options ??= new CopyOptions(); + + _logger.LogDebug("Copying: {Source} -> {Destination}", source, destination); + + // Check if source is a file + if (await FileExistsAsync(source, cancellationToken)) + { + await CopyFileAsync(source, destination, options, progress, cancellationToken); + } + else if (await DirectoryExistsAsync(source, cancellationToken)) + { + if (options.Recursive) + { + await CopyDirectoryAsync(source, destination, options, progress, cancellationToken); + } + else + { + throw new VfsOperationException("Source is a directory but recursive copying is disabled"); + } + } + else + { + throw new VfsNotFoundException(source); + } + + _logger.LogDebug("Copy completed: {Source} -> {Destination}", source, destination); + } + + private async Task CopyFileAsync( + VfsPath source, + VfsPath destination, + CopyOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var sourceFile = await GetFileAsync(source, cancellationToken); + var destinationFile = await GetFileAsync(destination, cancellationToken); + + if (await destinationFile.ExistsAsync(cancellationToken) && !options.Overwrite) + { + throw new VfsAlreadyExistsException(destination); + } + + progress?.Report(new CopyProgress + { + TotalFiles = 1, + TotalBytes = sourceFile.Size, + CurrentFile = source + }); + + // Copy content + await using var sourceStream = await sourceFile.OpenReadAsync(cancellationToken: cancellationToken); + await using var destinationStream = await destinationFile.OpenWriteAsync( + new WriteOptions { Overwrite = options.Overwrite }, cancellationToken); + + await sourceStream.CopyToAsync(destinationStream, cancellationToken); + + // Copy metadata if requested + if (options.PreserveMetadata) + { + var metadata = await sourceFile.GetMetadataAsync(cancellationToken); + if (metadata.Count > 0) + { + var metadataDict = new Dictionary(metadata); + await destinationFile.SetMetadataAsync(metadataDict, cancellationToken: cancellationToken); + } + } + + progress?.Report(new CopyProgress + { + TotalFiles = 1, + CopiedFiles = 1, + TotalBytes = sourceFile.Size, + CopiedBytes = sourceFile.Size, + CurrentFile = source + }); + } + + private async Task CopyDirectoryAsync( + VfsPath source, + VfsPath destination, + CopyOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var sourceDir = await GetDirectoryAsync(source, cancellationToken); + + // Calculate total work for progress reporting + var totalFiles = 0; + var totalBytes = 0L; + + await foreach (var entry in sourceDir.GetEntriesAsync(recursive: true, cancellationToken: cancellationToken)) + { + if (entry.Type == VfsEntryType.File && entry is IVirtualFile file) + { + totalFiles++; + totalBytes += file.Size; + } + } + + var copiedFiles = 0; + var copiedBytes = 0L; + + await foreach (var entry in sourceDir.GetEntriesAsync(recursive: true, cancellationToken: cancellationToken)) + { + if (entry.Type == VfsEntryType.File && entry is IVirtualFile sourceFile) + { + var relativePath = entry.Path.Value[source.Value.Length..].TrimStart('/'); + var destPath = destination.Combine(relativePath); + var destFile = await GetFileAsync(destPath, cancellationToken); + + if (await destFile.ExistsAsync(cancellationToken) && !options.Overwrite) + { + continue; // Skip existing files + } + + progress?.Report(new CopyProgress + { + TotalFiles = totalFiles, + CopiedFiles = copiedFiles, + TotalBytes = totalBytes, + CopiedBytes = copiedBytes, + CurrentFile = entry.Path + }); + + // Copy file content + await using var sourceStream = await sourceFile.OpenReadAsync(cancellationToken: cancellationToken); + await using var destStream = await destFile.OpenWriteAsync( + new WriteOptions { Overwrite = options.Overwrite }, cancellationToken); + + await sourceStream.CopyToAsync(destStream, cancellationToken); + + // Copy metadata if requested + if (options.PreserveMetadata) + { + var metadata = await sourceFile.GetMetadataAsync(cancellationToken); + if (metadata.Count > 0) + { + var metadataDict = new Dictionary(metadata); + await destFile.SetMetadataAsync(metadataDict, cancellationToken: cancellationToken); + } + } + + copiedFiles++; + copiedBytes += sourceFile.Size; + } + } + + progress?.Report(new CopyProgress + { + TotalFiles = totalFiles, + CopiedFiles = copiedFiles, + TotalBytes = totalBytes, + CopiedBytes = copiedBytes + }); + } + + /// + public async ValueTask GetEntryAsync(VfsPath path, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (await FileExistsAsync(path, cancellationToken)) + { + return await GetFileAsync(path, cancellationToken); + } + + if (await DirectoryExistsAsync(path, cancellationToken)) + { + return await GetDirectoryAsync(path, cancellationToken); + } + + return null; + } + + /// + public async IAsyncEnumerable ListAsync( + VfsPath path, + ListOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + options ??= new ListOptions(); + + var directory = await GetDirectoryAsync(path, cancellationToken); + + await foreach (var entry in directory.GetEntriesAsync( + options.Pattern, + options.Recursive, + options.PageSize, + cancellationToken)) + { + if (entry.Type == VfsEntryType.File && !options.IncludeFiles) + continue; + + if (entry.Type == VfsEntryType.Directory && !options.IncludeDirectories) + continue; + + yield return entry; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + _logger.LogDebug("Disposing VirtualFileSystem"); + _disposed = true; + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(VirtualFileSystem)); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/ManagedCode.Storage.VirtualFileSystem.csproj b/ManagedCode.Storage.VirtualFileSystem/ManagedCode.Storage.VirtualFileSystem.csproj new file mode 100644 index 0000000..ee0ecc9 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/ManagedCode.Storage.VirtualFileSystem.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + 13 + enable + true + + CS1591 + ManagedCode.Storage.VirtualFileSystem + ManagedCode.Storage.VirtualFileSystem + Virtual FileSystem abstraction over ManagedCode.Storage blob providers + ManagedCode + https://github.com/managedcode/Storage + https://github.com/managedcode/Storage + git + storage;blob;azure;aws;s3;gcp;filesystem;virtual;vfs + MIT + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs new file mode 100644 index 0000000..8105d21 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Storage.Core.Models; + +namespace ManagedCode.Storage.VirtualFileSystem.Metadata; + +/// +/// Interface for managing metadata on blob storage providers +/// +public interface IMetadataManager +{ + /// + /// Sets VFS metadata on a blob + /// + /// Name of the blob + /// VFS metadata to set + /// Additional custom metadata + /// Expected ETag for concurrency control + /// Cancellation token + /// Task representing the async operation + Task SetVfsMetadataAsync( + string blobName, + VfsMetadata metadata, + IDictionary? customMetadata = null, + string? expectedETag = null, + CancellationToken cancellationToken = default); + + /// + /// Gets VFS metadata from a blob + /// + /// Name of the blob + /// Cancellation token + /// VFS metadata or null if not found + Task GetVfsMetadataAsync( + string blobName, + CancellationToken cancellationToken = default); + + /// + /// Gets custom metadata from a blob + /// + /// Name of the blob + /// Cancellation token + /// Custom metadata dictionary + Task> GetCustomMetadataAsync( + string blobName, + CancellationToken cancellationToken = default); + + /// + /// Checks if a blob exists and gets its basic information + /// + /// Name of the blob + /// Cancellation token + /// Blob metadata or null if not found + Task GetBlobInfoAsync( + string blobName, + CancellationToken cancellationToken = default); +} + +/// +/// VFS-specific metadata for files and directories +/// +public class VfsMetadata +{ + /// + /// VFS metadata version for compatibility + /// + public string Version { get; set; } = "1.0"; + + /// + /// When the entry was created + /// + public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; + + /// + /// When the entry was last modified + /// + public DateTimeOffset Modified { get; set; } = DateTimeOffset.UtcNow; + + /// + /// VFS entry attributes + /// + public VfsAttributes Attributes { get; set; } = VfsAttributes.None; + + /// + /// Custom metadata specific to this entry + /// + public Dictionary CustomMetadata { get; set; } = new(); +} + +/// +/// VFS file/directory attributes +/// +[Flags] +public enum VfsAttributes +{ + /// + /// No special attributes + /// + None = 0, + + /// + /// Hidden entry + /// + Hidden = 1, + + /// + /// System entry + /// + System = 2, + + /// + /// Read-only entry + /// + ReadOnly = 4, + + /// + /// Archive entry + /// + Archive = 8, + + /// + /// Temporary entry + /// + Temporary = 16, + + /// + /// Compressed entry + /// + Compressed = 32 +} + +/// +/// Cache entry for metadata +/// +internal class MetadataCacheEntry +{ + public VfsMetadata Metadata { get; set; } = null!; + public IReadOnlyDictionary CustomMetadata { get; set; } = new Dictionary(); + public DateTimeOffset CachedAt { get; set; } = DateTimeOffset.UtcNow; + public string? ETag { get; set; } + public long Size { get; set; } + public string? ContentType { get; set; } +} + +/// +/// Base implementation for metadata managers +/// +public abstract class BaseMetadataManager : IMetadataManager +{ + protected const string VFS_VERSION_KEY = "vfs-version"; + protected const string VFS_CREATED_KEY = "vfs-created"; + protected const string VFS_MODIFIED_KEY = "vfs-modified"; + protected const string VFS_ATTRIBUTES_KEY = "vfs-attributes"; + protected const string VFS_CUSTOM_PREFIX = "vfs-"; + + protected abstract string MetadataPrefix { get; } + + public abstract Task SetVfsMetadataAsync( + string blobName, + VfsMetadata metadata, + IDictionary? customMetadata = null, + string? expectedETag = null, + CancellationToken cancellationToken = default); + + public abstract Task GetVfsMetadataAsync( + string blobName, + CancellationToken cancellationToken = default); + + public abstract Task> GetCustomMetadataAsync( + string blobName, + CancellationToken cancellationToken = default); + + public abstract Task GetBlobInfoAsync( + string blobName, + CancellationToken cancellationToken = default); + + /// + /// Builds metadata dictionary for storage + /// + protected Dictionary BuildMetadataDictionary( + VfsMetadata metadata, + IDictionary? customMetadata = null) + { + var dict = new Dictionary + { + [$"{MetadataPrefix}{VFS_VERSION_KEY}"] = metadata.Version, + [$"{MetadataPrefix}{VFS_CREATED_KEY}"] = metadata.Created.ToString("O"), + [$"{MetadataPrefix}{VFS_MODIFIED_KEY}"] = metadata.Modified.ToString("O"), + [$"{MetadataPrefix}{VFS_ATTRIBUTES_KEY}"] = ((int)metadata.Attributes).ToString() + }; + + // Add VFS custom metadata + foreach (var kvp in metadata.CustomMetadata) + { + dict[$"{MetadataPrefix}{VFS_CUSTOM_PREFIX}{kvp.Key}"] = kvp.Value; + } + + // Add additional custom metadata + if (customMetadata != null) + { + foreach (var kvp in customMetadata) + { + if (!kvp.Key.StartsWith(MetadataPrefix)) + { + dict[$"{MetadataPrefix}{kvp.Key}"] = kvp.Value; + } + else + { + dict[kvp.Key] = kvp.Value; + } + } + } + + return dict; + } + + /// + /// Parses VFS metadata from storage metadata + /// + protected VfsMetadata? ParseVfsMetadata(IDictionary storageMetadata) + { + var versionKey = $"{MetadataPrefix}{VFS_VERSION_KEY}"; + if (!storageMetadata.TryGetValue(versionKey, out var version)) + return null; // Not VFS metadata + + var metadata = new VfsMetadata { Version = version }; + + // Parse created date + var createdKey = $"{MetadataPrefix}{VFS_CREATED_KEY}"; + if (storageMetadata.TryGetValue(createdKey, out var createdStr) && + DateTimeOffset.TryParse(createdStr, out var created)) + { + metadata.Created = created; + } + + // Parse modified date + var modifiedKey = $"{MetadataPrefix}{VFS_MODIFIED_KEY}"; + if (storageMetadata.TryGetValue(modifiedKey, out var modifiedStr) && + DateTimeOffset.TryParse(modifiedStr, out var modified)) + { + metadata.Modified = modified; + } + + // Parse attributes + var attributesKey = $"{MetadataPrefix}{VFS_ATTRIBUTES_KEY}"; + if (storageMetadata.TryGetValue(attributesKey, out var attributesStr) && + int.TryParse(attributesStr, out var attributes)) + { + metadata.Attributes = (VfsAttributes)attributes; + } + + // Parse custom metadata + var customPrefix = $"{MetadataPrefix}{VFS_CUSTOM_PREFIX}"; + foreach (var kvp in storageMetadata) + { + if (kvp.Key.StartsWith(customPrefix)) + { + var customKey = kvp.Key[customPrefix.Length..]; + metadata.CustomMetadata[customKey] = kvp.Value; + } + } + + return metadata; + } + + /// + /// Extracts custom metadata (non-VFS) from storage metadata + /// + protected Dictionary ExtractCustomMetadata(IDictionary storageMetadata) + { + var result = new Dictionary(); + + foreach (var kvp in storageMetadata) + { + if (kvp.Key.StartsWith(MetadataPrefix)) + { + // Skip VFS system metadata + if (kvp.Key.EndsWith(VFS_VERSION_KEY) || + kvp.Key.EndsWith(VFS_CREATED_KEY) || + kvp.Key.EndsWith(VFS_MODIFIED_KEY) || + kvp.Key.EndsWith(VFS_ATTRIBUTES_KEY) || + kvp.Key.Contains($"{VFS_CUSTOM_PREFIX}")) + { + continue; + } + + // Include other custom metadata + var key = kvp.Key[MetadataPrefix.Length..]; + result[key] = kvp.Value; + } + } + + return result; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs b/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs new file mode 100644 index 0000000..74d4f78 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; + +namespace ManagedCode.Storage.VirtualFileSystem.Options; + +/// +/// Configuration options for Virtual File System +/// +public class VfsOptions +{ + /// + /// Default container name for blob storage + /// + public string DefaultContainer { get; set; } = "vfs"; + + /// + /// Strategy for handling directories + /// + public DirectoryStrategy DirectoryStrategy { get; set; } = DirectoryStrategy.Virtual; + + /// + /// Enable metadata caching for performance + /// + public bool EnableCache { get; set; } = true; + + /// + /// Cache time-to-live for metadata + /// + public TimeSpan CacheTTL { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Maximum number of cache entries + /// + public int MaxCacheEntries { get; set; } = 10000; + + /// + /// Default page size for directory listings + /// + public int DefaultPageSize { get; set; } = 100; + + /// + /// Maximum concurrent operations + /// + public int MaxConcurrency { get; set; } = 100; + + /// + /// Threshold for multipart upload (bytes) + /// + public long MultipartThreshold { get; set; } = 104857600; // 100MB +} + +/// +/// Options for write operations with concurrency control +/// +public class WriteOptions +{ + /// + /// Expected ETag for optimistic concurrency control + /// + public string? ExpectedETag { get; set; } + + /// + /// Whether to overwrite if the file exists + /// + public bool Overwrite { get; set; } = true; + + /// + /// Content type to set on the blob + /// + public string? ContentType { get; set; } + + /// + /// Custom metadata to add to the blob + /// + public Dictionary? Metadata { get; set; } +} + +/// +/// Streaming options for large files +/// +public class StreamOptions +{ + /// + /// Buffer size for streaming operations (default: 81920 bytes) + /// + public int BufferSize { get; set; } = 81920; + + /// + /// Range start for partial reads + /// + public long? RangeStart { get; set; } + + /// + /// Range end for partial reads + /// + public long? RangeEnd { get; set; } + + /// + /// Use async I/O for better performance + /// + public bool UseAsyncIO { get; set; } = true; +} + +/// +/// Options for listing directory contents +/// +public class ListOptions +{ + /// + /// Search pattern for filtering entries + /// + public SearchPattern? Pattern { get; set; } + + /// + /// Whether to list recursively + /// + public bool Recursive { get; set; } = false; + + /// + /// Page size for pagination + /// + public int PageSize { get; set; } = 100; + + /// + /// Include files in the results + /// + public bool IncludeFiles { get; set; } = true; + + /// + /// Include directories in the results + /// + public bool IncludeDirectories { get; set; } = true; +} + +/// +/// Options for move operations +/// +public class MoveOptions +{ + /// + /// Whether to overwrite the destination if it exists + /// + public bool Overwrite { get; set; } = false; + + /// + /// Whether to preserve metadata during the move + /// + public bool PreserveMetadata { get; set; } = true; +} + +/// +/// Options for copy operations +/// +public class CopyOptions +{ + /// + /// Whether to overwrite the destination if it exists + /// + public bool Overwrite { get; set; } = false; + + /// + /// Whether to preserve metadata during the copy + /// + public bool PreserveMetadata { get; set; } = true; + + /// + /// Whether to copy recursively for directories + /// + public bool Recursive { get; set; } = true; +} + +/// +/// Options for creating files +/// +public class CreateFileOptions +{ + /// + /// Content type to set on the file + /// + public string? ContentType { get; set; } + + /// + /// Initial metadata for the file + /// + public Dictionary? Metadata { get; set; } + + /// + /// Whether to overwrite if the file already exists + /// + public bool Overwrite { get; set; } = false; +} + +/// +/// Strategy for handling empty directories +/// +public enum DirectoryStrategy +{ + /// + /// Directories exist only if they contain files (virtual) + /// + Virtual, + + /// + /// Create zero-byte blob with trailing slash for empty directories + /// + ZeroByteMarker, + + /// + /// Create .keep file like git for empty directories + /// + DotKeepFile +} + +/// +/// Search pattern for filtering entries +/// +public class SearchPattern +{ + /// + /// Initializes a new instance of SearchPattern + /// + /// The pattern string (supports * and ? wildcards) + public SearchPattern(string pattern) + { + Pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); + } + + /// + /// The pattern string + /// + public string Pattern { get; } + + /// + /// Whether the pattern is case sensitive + /// + public bool CaseSensitive { get; set; } = false; + + /// + /// Checks if a name matches this pattern + /// + /// The name to check + /// True if the name matches the pattern + public bool IsMatch(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + + var comparison = CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + return IsWildcardMatch(Pattern, name, comparison); + } + + private static bool IsWildcardMatch(string pattern, string input, StringComparison comparison) + { + int patternIndex = 0; + int inputIndex = 0; + int starIndex = -1; + int match = 0; + + while (inputIndex < input.Length) + { + if (patternIndex < pattern.Length && (pattern[patternIndex] == '?' || + string.Equals(pattern[patternIndex].ToString(), input[inputIndex].ToString(), comparison))) + { + patternIndex++; + inputIndex++; + } + else if (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + starIndex = patternIndex; + match = inputIndex; + patternIndex++; + } + else if (starIndex != -1) + { + patternIndex = starIndex + 1; + match++; + inputIndex = match; + } + else + { + return false; + } + } + + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + patternIndex++; + } + + return patternIndex == pattern.Length; + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs new file mode 100644 index 0000000..ea0e530 --- /dev/null +++ b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ManagedCode.Storage.Core; +using ManagedCode.Storage.Core.Models; +using ManagedCode.Storage.VirtualFileSystem.Exceptions; +using ManagedCode.Storage.VirtualFileSystem.Options; + +namespace ManagedCode.Storage.VirtualFileSystem.Streaming; + +/// +/// Write stream implementation for VFS that buffers data and uploads on dispose +/// +internal class VfsWriteStream : Stream +{ + private readonly IStorage _storage; + private readonly string _blobKey; + private readonly WriteOptions _options; + private readonly IMemoryCache _cache; + private readonly VfsOptions _vfsOptions; + private readonly ILogger _logger; + private readonly MemoryStream _buffer; + private bool _disposed; + + public VfsWriteStream( + IStorage storage, + string blobKey, + WriteOptions options, + IMemoryCache cache, + VfsOptions vfsOptions, + ILogger logger) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _blobKey = blobKey ?? throw new ArgumentNullException(nameof(blobKey)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _vfsOptions = vfsOptions ?? throw new ArgumentNullException(nameof(vfsOptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _buffer = new MemoryStream(); + } + + public override bool CanRead => false; + public override bool CanSeek => _buffer.CanSeek; + public override bool CanWrite => !_disposed && _buffer.CanWrite; + public override long Length => _buffer.Length; + + public override long Position + { + get => _buffer.Position; + set => _buffer.Position = value; + } + + public override void Flush() + { + _buffer.Flush(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + await _buffer.FlushAsync(cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Read operations are not supported on write streams"); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _buffer.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _buffer.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + _buffer.Write(buffer, offset, count); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + await _buffer.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + await _buffer.WriteAsync(buffer, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + try + { + // Upload the buffered data + UploadBufferedDataAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading data during stream dispose: {BlobKey}", _blobKey); + } + finally + { + _buffer.Dispose(); + _disposed = true; + } + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + try + { + await UploadBufferedDataAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading data during stream dispose: {BlobKey}", _blobKey); + throw; + } + finally + { + await _buffer.DisposeAsync(); + _disposed = true; + } + } + + await base.DisposeAsync(); + } + + private async Task UploadBufferedDataAsync() + { + if (_buffer.Length == 0) + { + _logger.LogDebug("No data to upload for: {BlobKey}", _blobKey); + return; + } + + _logger.LogDebug("Uploading buffered data: {BlobKey}, size: {Size}", _blobKey, _buffer.Length); + + try + { + _buffer.Position = 0; + + var uploadOptions = new UploadOptions(_blobKey) + { + MimeType = _options.ContentType, + Metadata = _options.Metadata + }; + + var result = await _storage.UploadAsync(_buffer, uploadOptions); + + if (!result.IsSuccess) + { + throw new VfsOperationException($"Failed to upload data for: {_blobKey}. Error: {result.Problem}"); + } + + // Invalidate cache after successful upload + if (_vfsOptions.EnableCache) + { + var cacheKey = $"file_exists:{_vfsOptions.DefaultContainer}:{_blobKey}"; + _cache.Remove(cacheKey); + var metadataCacheKey = $"file_metadata:{_vfsOptions.DefaultContainer}:{_blobKey}"; + _cache.Remove(metadataCacheKey); + } + + _logger.LogDebug("Successfully uploaded data: {BlobKey}", _blobKey); + } + catch (Exception ex) when (!(ex is VfsOperationException)) + { + _logger.LogError(ex, "Error uploading buffered data: {BlobKey}", _blobKey); + throw new VfsOperationException($"Failed to upload data for: {_blobKey}", ex); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(VfsWriteStream)); + } +} \ No newline at end of file diff --git a/ManagedCode.Storage.sln b/ManagedCode.Storage.sln index 63d139e..d0b51ba 100644 --- a/ManagedCode.Storage.sln +++ b/ManagedCode.Storage.sln @@ -31,72 +31,178 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Storage.Client. EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E609A83E-6400-42B0-AD5A-5B006EABC275}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Storage.VirtualFileSystem", "ManagedCode.Storage.VirtualFileSystem\ManagedCode.Storage.VirtualFileSystem.csproj", "{C57B07B3-B28F-4ABD-AFA9-F3D70B174100}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|x64.Build.0 = Debug|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Debug|x86.Build.0 = Debug|Any CPU {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|Any CPU.ActiveCfg = Release|Any CPU {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|Any CPU.Build.0 = Release|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|x64.ActiveCfg = Release|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|x64.Build.0 = Release|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|x86.ActiveCfg = Release|Any CPU + {1B494908-A80A-4EEE-97A7-ABDEAC3EC64F}.Release|x86.Build.0 = Release|Any CPU {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|x64.Build.0 = Debug|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Debug|x86.Build.0 = Debug|Any CPU {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|Any CPU.Build.0 = Release|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|x64.ActiveCfg = Release|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|x64.Build.0 = Release|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|x86.ActiveCfg = Release|Any CPU + {0D6304D1-911D-489E-A716-6CBD5D0FE05D}.Release|x86.Build.0 = Release|Any CPU {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|x64.Build.0 = Debug|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Debug|x86.Build.0 = Debug|Any CPU {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|Any CPU.ActiveCfg = Release|Any CPU {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|Any CPU.Build.0 = Release|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|x64.ActiveCfg = Release|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|x64.Build.0 = Release|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|x86.ActiveCfg = Release|Any CPU + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F}.Release|x86.Build.0 = Release|Any CPU {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|x64.Build.0 = Debug|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Debug|x86.Build.0 = Debug|Any CPU {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|Any CPU.Build.0 = Release|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|x64.ActiveCfg = Release|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|x64.Build.0 = Release|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|x86.ActiveCfg = Release|Any CPU + {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F}.Release|x86.Build.0 = Release|Any CPU {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|x64.Build.0 = Debug|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Debug|x86.Build.0 = Debug|Any CPU {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|Any CPU.Build.0 = Release|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|x64.ActiveCfg = Release|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|x64.Build.0 = Release|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|x86.ActiveCfg = Release|Any CPU + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3}.Release|x86.Build.0 = Release|Any CPU {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|x64.Build.0 = Debug|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Debug|x86.Build.0 = Debug|Any CPU {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|Any CPU.Build.0 = Release|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|x64.ActiveCfg = Release|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|x64.Build.0 = Release|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|x86.ActiveCfg = Release|Any CPU + {EDFA1CB7-1721-4447-9C25-AE110821717C}.Release|x86.Build.0 = Release|Any CPU {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|x64.Build.0 = Debug|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Debug|x86.Build.0 = Debug|Any CPU {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|Any CPU.ActiveCfg = Release|Any CPU {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|Any CPU.Build.0 = Release|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|x64.ActiveCfg = Release|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|x64.Build.0 = Release|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|x86.ActiveCfg = Release|Any CPU + {852B0DBD-37F0-4DC0-B966-C284AE03C2F5}.Release|x86.Build.0 = Release|Any CPU {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|x64.Build.0 = Debug|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Debug|x86.Build.0 = Debug|Any CPU {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|Any CPU.Build.0 = Release|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|x64.ActiveCfg = Release|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|x64.Build.0 = Release|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|x86.ActiveCfg = Release|Any CPU + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690}.Release|x86.Build.0 = Release|Any CPU {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|x64.ActiveCfg = Debug|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|x64.Build.0 = Debug|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|x86.ActiveCfg = Debug|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Debug|x86.Build.0 = Debug|Any CPU {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|Any CPU.ActiveCfg = Release|Any CPU {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|Any CPU.Build.0 = Release|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|x64.ActiveCfg = Release|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|x64.Build.0 = Release|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|x86.ActiveCfg = Release|Any CPU + {7190B548-4BE9-4EF6-B55F-8432757AEAD5}.Release|x86.Build.0 = Release|Any CPU {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|x64.Build.0 = Debug|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Debug|x86.Build.0 = Debug|Any CPU {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|Any CPU.Build.0 = Release|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|x64.ActiveCfg = Release|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|x64.Build.0 = Release|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|x86.ActiveCfg = Release|Any CPU + {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C}.Release|x86.Build.0 = Release|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|x64.ActiveCfg = Debug|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|x64.Build.0 = Debug|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|x86.ActiveCfg = Debug|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Debug|x86.Build.0 = Debug|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|Any CPU.ActiveCfg = Release|Any CPU {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|Any CPU.Build.0 = Release|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|x64.ActiveCfg = Release|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|x64.Build.0 = Release|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|x86.ActiveCfg = Release|Any CPU + {ED216AAD-CBA2-40F2-AA01-63C60E906632}.Release|x86.Build.0 = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|x64.ActiveCfg = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|x64.Build.0 = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|x86.ActiveCfg = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Debug|x86.Build.0 = Debug|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|Any CPU.Build.0 = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|x64.ActiveCfg = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|x64.Build.0 = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|x86.ActiveCfg = Release|Any CPU + {C57B07B3-B28F-4ABD-AFA9-F3D70B174100}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A594F814-80A8-49D2-B751-B3A58869B30D} - EndGlobalSection GlobalSection(NestedProjects) = preSolution - {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3} = {92201402-E361-440F-95DB-68663D228C2D} - {4D4D2AC7-923D-4219-9BC9-341FBA7FE690} = {92201402-E361-440F-95DB-68663D228C2D} {0D6304D1-911D-489E-A716-6CBD5D0FE05D} = {92201402-E361-440F-95DB-68663D228C2D} + {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F} = {E609A83E-6400-42B0-AD5A-5B006EABC275} {0AFE156D-0DA5-4B23-8262-CA98E4C0FB5F} = {92201402-E361-440F-95DB-68663D228C2D} + {C3B4FF9C-1C6A-4EA0-9291-E7E0C0EF2BA3} = {92201402-E361-440F-95DB-68663D228C2D} {EDFA1CB7-1721-4447-9C25-AE110821717C} = {92201402-E361-440F-95DB-68663D228C2D} {852B0DBD-37F0-4DC0-B966-C284AE03C2F5} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} + {4D4D2AC7-923D-4219-9BC9-341FBA7FE690} = {92201402-E361-440F-95DB-68663D228C2D} {D5A7D3A7-E6E8-4153-911D-D7C0C5C8B19C} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} {ED216AAD-CBA2-40F2-AA01-63C60E906632} = {94DB7354-F5C7-4347-B9EC-FCCA38B86876} - {F9DA9E52-2DDF-40E3-B0A4-4EC7B118FE8F} = {E609A83E-6400-42B0-AD5A-5B006EABC275} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A594F814-80A8-49D2-B751-B3A58869B30D} EndGlobalSection EndGlobal