diff --git a/src/Ramstack.FileSystem.Abstractions/Internal/ThrowHelper.cs b/src/Ramstack.FileSystem.Abstractions/Internal/ThrowHelper.cs index b8ea490..7ef8725 100644 --- a/src/Ramstack.FileSystem.Abstractions/Internal/ThrowHelper.cs +++ b/src/Ramstack.FileSystem.Abstractions/Internal/ThrowHelper.cs @@ -21,8 +21,8 @@ public static void ChangesNotSupported() => [DoesNotReturn] public static void PathMappingFailed() { - const string message = "The virtual path could not be mapped to a physical path. The parent directory may not exist or be accessible."; - throw new InvalidOperationException(message); + const string Message = "The virtual path could not be mapped to a physical path. The parent directory may not exist or be accessible."; + throw new InvalidOperationException(Message); } /// diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs b/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs index 2ffcac9..3d5f8cd 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs @@ -196,4 +196,58 @@ protected virtual async ValueTask CopyCoreAsync(string destinationPath, bool ove await using var source = await OpenReadAsync(cancellationToken).ConfigureAwait(false); await FileSystem.WriteFileAsync(destinationPath, source, overwrite, cancellationToken).ConfigureAwait(false); } + + /// + /// Asynchronously copies the contents of the current to the specified destination . + /// + /// The destination where the contents will be copied to. + /// to overwrite an existing file; to throw an exception if the file already exists. + /// An optional cancellation token to cancel the operation. + /// + /// A that represents the asynchronous copy operation. + /// + /// + /// + /// If the file does not exist, it will be created. + /// If it exists and is , the existing file will be overwritten. + /// If is and the file exists, an exception will be thrown. + /// + /// + public ValueTask CopyToAsync(VirtualFile destination, bool overwrite, CancellationToken cancellationToken = default) + { + if (destination.IsReadOnly) + ThrowHelper.ChangesNotSupported(); + + return CopyToCoreAsync(destination, overwrite, cancellationToken); + } + + /// + /// Core implementation for asynchronously copying the contents of the current to the specified destination . + /// + /// The destination where the contents will be copied to. + /// to overwrite an existing file; to throw an exception if the file already exists. + /// An optional cancellation token to cancel the operation. + /// + /// A that represents the asynchronous copy operation. + /// + /// + /// + /// If the file does not exist, it will be created. + /// If it exists and is , the existing file will be overwritten. + /// If is and the file exists, an exception will be thrown. + /// + /// + protected virtual async ValueTask CopyToCoreAsync(VirtualFile destination, bool overwrite, CancellationToken cancellationToken) + { + if (FileSystem == destination.FileSystem) + { + await CopyAsync(destination.FullName, overwrite, cancellationToken).ConfigureAwait(false); + destination.Refresh(); + } + else + { + await using var stream = await OpenReadAsync(cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(stream, overwrite, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualFileExtensions.cs b/src/Ramstack.FileSystem.Abstractions/VirtualFileExtensions.cs index 9bf0860..1065130 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualFileExtensions.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualFileExtensions.cs @@ -61,37 +61,6 @@ public static ValueTask WriteAsync(this VirtualFile file, Stream stream, Cancell public static ValueTask CopyToAsync(this VirtualFile file, VirtualFile destination, CancellationToken cancellationToken = default) => file.CopyToAsync(destination, overwrite: false, cancellationToken); - /// - /// Asynchronously copies the contents of the current to the specified destination . - /// - /// The source to copy from. - /// The destination where the contents will be copied to. - /// to overwrite an existing file; to throw an exception if the file already exists. - /// A token to cancel the operation. Defaults to . - /// - /// A that represents the asynchronous copy operation. - /// - /// - /// - /// If the file does not exist, it will be created. - /// If it exists and is , the existing file will be overwritten. - /// If is and the file exists, an exception will be thrown. - /// - /// - public static async ValueTask CopyToAsync(this VirtualFile file, VirtualFile destination, bool overwrite, CancellationToken cancellationToken = default) - { - if (file.FileSystem == destination.FileSystem) - { - await file.CopyAsync(destination.FullName, overwrite, cancellationToken).ConfigureAwait(false); - destination.Refresh(); - } - else - { - await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); - await destination.WriteAsync(stream, overwrite, cancellationToken).ConfigureAwait(false); - } - } - /// /// Asynchronously returns the size of the specified file in bytes. /// diff --git a/src/Ramstack.FileSystem.Amazon/AccessControl.cs b/src/Ramstack.FileSystem.Amazon/AccessControl.cs new file mode 100644 index 0000000..cfdb0ff --- /dev/null +++ b/src/Ramstack.FileSystem.Amazon/AccessControl.cs @@ -0,0 +1,66 @@ +namespace Ramstack.FileSystem.Amazon; + +/// +/// An enumeration of all possible CannedACLs that can be used +/// for S3 Buckets or S3 Objects. For more information about CannedACLs, refer to +/// . +/// +public enum AccessControl +{ + /// + /// Owner gets FULL_CONTROL. + /// No one else has access rights (default). + /// + NoAcl, + + /// + /// Owner gets FULL_CONTROL. + /// No one else has access rights (default). + /// + Private, + + /// + /// Owner gets FULL_CONTROL and the anonymous principal is granted READ access. + /// If this policy is used on an object, it can be read from a browser with no authentication. + /// + PublicRead, + + /// + /// Owner gets FULL_CONTROL, the anonymous principal is granted READ and WRITE access. + /// This can be a useful policy to apply to a bucket, but is generally not recommended. + /// + PublicReadWrite, + + /// + /// Owner gets FULL_CONTROL, and any principal authenticated as a registered Amazon + /// S3 user is granted READ access. + /// + AuthenticatedRead, + + /// + /// Owner gets FULL_CONTROL. Amazon EC2 gets READ access to GET an + /// Amazon Machine Image (AMI) bundle from Amazon S3. + /// + AwsExecRead, + + /// + /// Object Owner gets FULL_CONTROL, Bucket Owner gets READ + /// This ACL applies only to objects and is equivalent to private when used with PUT Bucket. + /// You use this ACL to let someone other than the bucket owner write content (get full control) + /// in the bucket but still grant the bucket owner read access to the objects. + /// + BucketOwnerRead, + + /// + /// Object Owner gets FULL_CONTROL, Bucket Owner gets FULL_CONTROL. + /// This ACL applies only to objects and is equivalent to private when used with PUT Bucket. + /// You use this ACL to let someone other than the bucket owner write content (get full control) + /// in the bucket but still grant the bucket owner full rights over the objects. + /// + BucketOwnerFullControl, + + /// + /// The LogDelivery group gets WRITE and READ_ACP permissions on the bucket. + /// + LogDeliveryWrite +} diff --git a/src/Ramstack.FileSystem.Amazon/AmazonFile.cs b/src/Ramstack.FileSystem.Amazon/AmazonFile.cs deleted file mode 100644 index 58b0c35..0000000 --- a/src/Ramstack.FileSystem.Amazon/AmazonFile.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Net; - -using Amazon.S3; -using Amazon.S3.Model; - -namespace Ramstack.FileSystem.Amazon; - -/// -/// Represents an implementation of that maps a file to an object in Amazon S3. -/// -internal sealed class AmazonFile : VirtualFile -{ - private readonly AmazonS3FileSystem _fs; - private readonly string _key; - - /// - public override IVirtualFileSystem FileSystem => _fs; - - /// - /// Initializes a new instance of the class. - /// - /// The file system associated with this file. - /// The path to the file. - public AmazonFile(AmazonS3FileSystem fileSystem, string path) : base(path) => - (_fs, _key) = (fileSystem, path[1..]); - - /// - protected override async ValueTask GetPropertiesCoreAsync(CancellationToken cancellationToken) - { - try - { - var metadata = await _fs.AmazonClient - .GetObjectMetadataAsync( - new GetObjectMetadataRequest { BucketName = _fs.BucketName, Key = _key }, - cancellationToken) - .ConfigureAwait(false); - - return VirtualNodeProperties.CreateFileProperties( - creationTime: default, - lastAccessTime: default, - lastWriteTime: metadata.LastModified, - length: metadata.ContentLength); - } - catch (AmazonS3Exception e) when (e.StatusCode == HttpStatusCode.NotFound) - { - return null; - } - } - - /// - protected override async ValueTask OpenReadCoreAsync(CancellationToken cancellationToken) - { - var response = await _fs.AmazonClient - .GetObjectAsync( - new GetObjectRequest { BucketName = _fs.BucketName, Key = _key }, - cancellationToken) - .ConfigureAwait(false); - - return response.ResponseStream; - } - - /// - protected override async ValueTask OpenWriteCoreAsync(CancellationToken cancellationToken) - { - var response = await _fs.AmazonClient - .InitiateMultipartUploadAsync(_fs.BucketName, _key, cancellationToken) - .ConfigureAwait(false); - - return new AmazonS3UploadStream(_fs.AmazonClient, _fs.BucketName, _key, response.UploadId); - } - - /// - protected override async ValueTask WriteCoreAsync(Stream stream, bool overwrite, CancellationToken cancellationToken) - { - var request = new PutObjectRequest - { - BucketName = _fs.BucketName, - Key = _key, - InputStream = stream, - AutoCloseStream = false - }; - - if (!overwrite) - request.IfNoneMatch = "*"; - - await _fs.AmazonClient - .PutObjectAsync(request, cancellationToken) - .ConfigureAwait(false); - } - - /// - protected override async ValueTask DeleteCoreAsync(CancellationToken cancellationToken) - { - try - { - await _fs.AmazonClient - .DeleteObjectAsync( - new DeleteObjectRequest { BucketName = _fs.BucketName, Key = _key }, - cancellationToken) - .ConfigureAwait(false); - } - catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - } - } -} diff --git a/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs b/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs index 5ea59ad..cb3c295 100644 --- a/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs +++ b/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs @@ -81,14 +81,14 @@ public AmazonS3FileSystem(string awsAccessKeyId, string awsSecretAccessKey, Regi public VirtualFile GetFile(string path) { path = VirtualPath.GetFullPath(path); - return new AmazonFile(this, path); + return new S3File(this, path); } /// public VirtualDirectory GetDirectory(string path) { path = VirtualPath.GetFullPath(path); - return new AmazonDirectory(this, path); + return new S3Directory(this, path); } /// @@ -102,7 +102,18 @@ public void Dispose() => /// /// A representing the asynchronous operation. /// - public async ValueTask CreateBucketAsync(CancellationToken cancellationToken = default) + public ValueTask CreateBucketAsync(CancellationToken cancellationToken = default) => + CreateBucketAsync(AccessControl.NoAcl, cancellationToken); + + /// + /// Creates the S3 bucket if it does not already exist. + /// + /// The ACL to apply to the bucket. + /// A cancellation token to cancel the operation. + /// + /// A representing the asynchronous operation. + /// + public async ValueTask CreateBucketAsync(AccessControl accessControl, CancellationToken cancellationToken = default) { var exists = AmazonS3Util .DoesS3BucketExistV2Async(AmazonClient, BucketName) @@ -114,7 +125,20 @@ public async ValueTask CreateBucketAsync(CancellationToken cancellationToken = d var request = new PutBucketRequest { BucketName = BucketName, - UseClientRegion = true + UseClientRegion = true, + CannedACL = accessControl switch + { + AccessControl.NoAcl => S3CannedACL.NoACL, + AccessControl.Private => S3CannedACL.Private, + AccessControl.PublicRead => S3CannedACL.PublicRead, + AccessControl.PublicReadWrite => S3CannedACL.PublicReadWrite, + AccessControl.AuthenticatedRead => S3CannedACL.AuthenticatedRead, + AccessControl.AwsExecRead => S3CannedACL.AWSExecRead, + AccessControl.BucketOwnerRead => S3CannedACL.BucketOwnerRead, + AccessControl.BucketOwnerFullControl => S3CannedACL.BucketOwnerFullControl, + AccessControl.LogDeliveryWrite => S3CannedACL.LogDeliveryWrite, + _ => throw new ArgumentOutOfRangeException(nameof(accessControl)) + } }; await AmazonClient diff --git a/src/Ramstack.FileSystem.Amazon/AmazonDirectory.cs b/src/Ramstack.FileSystem.Amazon/S3Directory.cs similarity index 87% rename from src/Ramstack.FileSystem.Amazon/AmazonDirectory.cs rename to src/Ramstack.FileSystem.Amazon/S3Directory.cs index 9197505..acae6a1 100644 --- a/src/Ramstack.FileSystem.Amazon/AmazonDirectory.cs +++ b/src/Ramstack.FileSystem.Amazon/S3Directory.cs @@ -10,7 +10,7 @@ namespace Ramstack.FileSystem.Amazon; /// Represents an implementation of that maps a directory /// to a path within a specified Amazon S3 bucket. /// -internal sealed class AmazonDirectory : VirtualDirectory +internal sealed class S3Directory : VirtualDirectory { private readonly AmazonS3FileSystem _fs; private readonly string _prefix; @@ -19,11 +19,11 @@ internal sealed class AmazonDirectory : VirtualDirectory public override IVirtualFileSystem FileSystem => _fs; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The file system associated with this directory. /// The path to the directory within the specified Amazon S3 bucket. - public AmazonDirectory(AmazonS3FileSystem fileSystem, string path) : base(path) + public S3Directory(AmazonS3FileSystem fileSystem, string path) : base(path) { _fs = fileSystem; _prefix = FullName == "/" ? "" : $"{FullName[1..]}/"; @@ -96,10 +96,10 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En .ConfigureAwait(false); foreach (var prefix in response.CommonPrefixes) - yield return new AmazonDirectory(_fs, VirtualPath.Normalize(prefix)); + yield return new S3Directory(_fs, VirtualPath.Normalize(prefix)); foreach (var obj in response.S3Objects) - yield return new AmazonFile(_fs, VirtualPath.Normalize(obj.Key)); + yield return new S3File(_fs, VirtualPath.Normalize(obj.Key)); request.ContinuationToken = response.NextContinuationToken; } diff --git a/src/Ramstack.FileSystem.Amazon/S3File.cs b/src/Ramstack.FileSystem.Amazon/S3File.cs new file mode 100644 index 0000000..8247759 --- /dev/null +++ b/src/Ramstack.FileSystem.Amazon/S3File.cs @@ -0,0 +1,165 @@ +using System.Net; + +using Amazon.S3; +using Amazon.S3.Model; + +namespace Ramstack.FileSystem.Amazon; + +/// +/// Represents an implementation of that maps a file to an object in Amazon S3. +/// +internal sealed class S3File : VirtualFile +{ + private readonly AmazonS3FileSystem _fs; + private readonly string _key; + + /// + public override IVirtualFileSystem FileSystem => _fs; + + /// + /// Initializes a new instance of the class. + /// + /// The file system associated with this file. + /// The path to the file. + public S3File(AmazonS3FileSystem fileSystem, string path) : base(path) => + (_fs, _key) = (fileSystem, path[1..]); + + /// + protected override async ValueTask GetPropertiesCoreAsync(CancellationToken cancellationToken) + { + try + { + var metadata = await _fs.AmazonClient + .GetObjectMetadataAsync( + new GetObjectMetadataRequest { BucketName = _fs.BucketName, Key = _key }, + cancellationToken) + .ConfigureAwait(false); + + return VirtualNodeProperties.CreateFileProperties( + creationTime: default, + lastAccessTime: default, + lastWriteTime: metadata.LastModified, + length: metadata.ContentLength); + } + catch (AmazonS3Exception e) when (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + /// + protected override async ValueTask OpenReadCoreAsync(CancellationToken cancellationToken) + { + var response = await _fs.AmazonClient + .GetObjectAsync( + new GetObjectRequest { BucketName = _fs.BucketName, Key = _key }, + cancellationToken) + .ConfigureAwait(false); + + return response.ResponseStream; + } + + /// + protected override async ValueTask OpenWriteCoreAsync(CancellationToken cancellationToken) + { + var response = await _fs.AmazonClient + .InitiateMultipartUploadAsync(_fs.BucketName, _key, cancellationToken) + .ConfigureAwait(false); + + return new S3UploadStream(_fs.AmazonClient, _fs.BucketName, _key, response.UploadId); + } + + /// + protected override async ValueTask WriteCoreAsync(Stream stream, bool overwrite, CancellationToken cancellationToken) + { + var request = new PutObjectRequest + { + BucketName = _fs.BucketName, + Key = _key, + InputStream = stream, + AutoCloseStream = false + }; + + if (!overwrite) + request.IfNoneMatch = "*"; + + await _fs.AmazonClient + .PutObjectAsync(request, cancellationToken) + .ConfigureAwait(false); + } + + /// + protected override async ValueTask DeleteCoreAsync(CancellationToken cancellationToken) + { + try + { + await _fs.AmazonClient + .DeleteObjectAsync( + new DeleteObjectRequest { BucketName = _fs.BucketName, Key = _key }, + cancellationToken) + .ConfigureAwait(false); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + } + } + + /// + protected override ValueTask CopyCoreAsync(string destinationPath, bool overwrite, CancellationToken cancellationToken) => + CopyObjectAsync(_fs.BucketName, _key, _fs.BucketName, destinationPath, overwrite, cancellationToken); + + /// + protected override ValueTask CopyToCoreAsync(VirtualFile destination, bool overwrite, CancellationToken cancellationToken) + { + return destination switch + { + S3File destinationFile => CopyObjectAsync(_fs.BucketName, _key, destinationFile._fs.BucketName, destinationFile._key, overwrite, cancellationToken), + _ => base.CopyToCoreAsync(destination, overwrite, cancellationToken) + }; + } + + /// + /// Asynchronously copies an object from the source bucket and key to the destination bucket and key. + /// + /// The name of the source S3 bucket. + /// The key of the source object in the S3 bucket. + /// The name of the destination S3 bucket. + /// The key of the destination object in the S3 bucket. + /// A boolean value indicating whether to overwrite the destination object if it already exists. + /// A cancellation token to cancel the operation. + /// + /// A that represents the asynchronous copy operation. + /// + private async ValueTask CopyObjectAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, bool overwrite, CancellationToken cancellationToken) + { + // Unfortunately, Amazon S3 does not support destination conditions, + // so we make a separate request to check for the destination object existence. + + if (!overwrite) + { + try + { + await _fs.AmazonClient + .GetObjectMetadataAsync(destinationBucket, destinationKey, cancellationToken) + .ConfigureAwait(false); + + throw new AmazonS3Exception($"An object already exists at destination: {destinationKey}"); + } + catch (AmazonS3Exception e) when (e.StatusCode == HttpStatusCode.NotFound) + { + } + } + + var request = new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }; + + await _fs.AmazonClient + .CopyObjectAsync(request, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/Ramstack.FileSystem.Amazon/AmazonS3UploadStream.cs b/src/Ramstack.FileSystem.Amazon/S3UploadStream.cs similarity index 63% rename from src/Ramstack.FileSystem.Amazon/AmazonS3UploadStream.cs rename to src/Ramstack.FileSystem.Amazon/S3UploadStream.cs index 453a895..c4089db 100644 --- a/src/Ramstack.FileSystem.Amazon/AmazonS3UploadStream.cs +++ b/src/Ramstack.FileSystem.Amazon/S3UploadStream.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Amazon.S3; using Amazon.S3.Model; @@ -12,7 +12,7 @@ namespace Ramstack.FileSystem.Amazon; /// This stream accumulates data in a temporary buffer and uploads it to S3 in parts /// once the buffer reaches a predefined size. /// -internal sealed class AmazonS3UploadStream : Stream +internal sealed class S3UploadStream : Stream { private const long PartSize = 5 * 1024 * 1024; @@ -57,37 +57,30 @@ public override long Position } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The Amazon S3 client used for uploading parts. /// The name of the S3 bucket where the data will be uploaded. /// The key (path) in the S3 bucket where the data will be stored. - /// The ID of the multipart upload session. - public AmazonS3UploadStream(IAmazonS3 client, string bucketName, string key, string uploadId) + /// The multipart upload session identifier. + public S3UploadStream(IAmazonS3 client, string bucketName, string key, string uploadId) { _client = client; _bucketName = bucketName; _key = key; _uploadId = uploadId; - _stream = CreateTempStream(); _partETags = []; - static FileStream CreateTempStream() - { - const FileOptions Options = - FileOptions.DeleteOnClose | - FileOptions.Asynchronous; - - return new FileStream( - Path.Combine( - Path.GetTempPath(), - Path.GetRandomFileName()), - FileMode.CreateNew, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 4096, - Options); - } + _stream = new FileStream( + Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName()), + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 4096, + FileOptions.DeleteOnClose + | FileOptions.Asynchronous); } /// @@ -118,12 +111,8 @@ public override void Write(ReadOnlySpan buffer) } /// - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - await _stream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); - if (_stream.Length >= PartSize) - await UploadPartAsync(cancellationToken).ConfigureAwait(false); - } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); /// public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new CancellationToken()) @@ -185,19 +174,9 @@ await _client .CompleteMultipartUploadAsync(request) .ConfigureAwait(false); } - catch (Exception) + catch { - var request = new AbortMultipartUploadRequest - { - BucketName = _bucketName, - Key = _key, - UploadId = _uploadId - }; - - await _client - .AbortMultipartUploadAsync(request) - .ConfigureAwait(false); - + await AbortAsync(CancellationToken.None).ConfigureAwait(false); throw; } finally @@ -232,35 +211,71 @@ private async ValueTask UploadPartAsync(CancellationToken cancellationToken) if (_stream.Length != 0 || _partETags.Count == 0) { - _stream.Position = 0; - - // TODO: Check limit - // https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html - // The maximum allowed part size is 5 gigabytes. - // Perhaps we should split a file exceeding this limit - // into smaller parts and upload them separately. - - var request = new UploadPartRequest + try { - BucketName = _bucketName, - Key = _key, - UploadId = _uploadId, - PartNumber = _partETags.Count + 1, - InputStream = _stream, - PartSize = _stream.Length - }; - - var response = await _client - .UploadPartAsync(request, cancellationToken) - .ConfigureAwait(false); - - _partETags.Add(new PartETag(response)); - - _stream.Position = 0; - _stream.SetLength(0); + _stream.Position = 0; + + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html + // The maximum allowed part size is 5 gigabytes. + + var request = new UploadPartRequest + { + BucketName = _bucketName, + Key = _key, + UploadId = _uploadId, + PartNumber = _partETags.Count + 1, + InputStream = _stream, + PartSize = _stream.Length + }; + + var response = await _client + .UploadPartAsync(request, cancellationToken) + .ConfigureAwait(false); + + _partETags.Add(new PartETag(response)); + + _stream.Position = 0; + _stream.SetLength(0); + } + catch + { + await AbortAsync(cancellationToken).ConfigureAwait(false); + throw; + } } } + /// + /// Aborts the multipart upload session. + /// + /// A cancellation token to cancel the operation. + /// + /// A that represents the asynchronous abort operation. + /// + /// + /// This method sends a request to Amazon S3 to abort the multipart upload identified by the + /// . Once aborted, the upload cannot be resumed, and any uploaded parts + /// will be deleted. + /// + private async ValueTask AbortAsync(CancellationToken cancellationToken) + { + var request = new AbortMultipartUploadRequest + { + BucketName = _bucketName, + Key = _key, + UploadId = _uploadId + }; + + await _client + .AbortMultipartUploadAsync(request, cancellationToken) + .ConfigureAwait(false); + + // Prevent subsequent writes to the stream. + await _stream + .DisposeAsync() + .ConfigureAwait(false); + } + /// /// Throws a . /// diff --git a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs index 31d904c..db27fc2 100644 --- a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs +++ b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs @@ -46,12 +46,12 @@ protected override ValueTask CreateCoreAsync(CancellationToken cancellationToken /// protected override async ValueTask DeleteCoreAsync(CancellationToken cancellationToken) { - var collection = _fs.Container + var collection = _fs.AzureClient .GetBlobsAsync( prefix: GetPrefix(FullName), cancellationToken: cancellationToken); - var client = _fs.Container.GetBlobBatchClient(); + var client = _fs.AzureClient.GetBlobBatchClient(); var batch = client.CreateBatch(); // https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch#remarks @@ -62,7 +62,7 @@ protected override async ValueTask DeleteCoreAsync(CancellationToken cancellatio { foreach (var blob in page.Values) { - batch.DeleteBlob(_fs.Container.Name, blob.Name, DeleteSnapshotsOption.IncludeSnapshots, conditions: null); + batch.DeleteBlob(_fs.AzureClient.Name, blob.Name, DeleteSnapshotsOption.IncludeSnapshots, conditions: null); if (batch.RequestCount != MaxSubRequests) continue; @@ -103,7 +103,7 @@ static bool Processed(ReadOnlyCollection exceptions) /// protected override async IAsyncEnumerable GetFileNodesCoreAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - var collection = _fs.Container + var collection = _fs.AzureClient .GetBlobsByHierarchyAsync( delimiter: "/", prefix: GetPrefix(FullName), @@ -119,7 +119,7 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En /// protected override async IAsyncEnumerable GetFilesCoreAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - var collection = _fs.Container + var collection = _fs.AzureClient .GetBlobsByHierarchyAsync( delimiter: "/", prefix: GetPrefix(FullName), @@ -134,7 +134,7 @@ protected override async IAsyncEnumerable GetFilesCoreAsync([Enumer /// protected override async IAsyncEnumerable GetDirectoriesCoreAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - var collection = _fs.Container + var collection = _fs.AzureClient .GetBlobsByHierarchyAsync( delimiter: "/", prefix: GetPrefix(FullName), diff --git a/src/Ramstack.FileSystem.Azure/AzureFile.cs b/src/Ramstack.FileSystem.Azure/AzureFile.cs index 45920bc..f1dd7ba 100644 --- a/src/Ramstack.FileSystem.Azure/AzureFile.cs +++ b/src/Ramstack.FileSystem.Azure/AzureFile.cs @@ -70,10 +70,7 @@ protected override ValueTask OpenWriteCoreAsync(CancellationToken cancel /// protected override ValueTask WriteCoreAsync(Stream stream, bool overwrite, CancellationToken cancellationToken) { - var options = new BlobUploadOptions - { - HttpHeaders = _fs.GetBlobHeaders(FullName) - }; + var options = new BlobUploadOptions(); if (!overwrite) { @@ -83,7 +80,6 @@ protected override ValueTask WriteCoreAsync(Stream stream, bool overwrite, Cance }; } - var task = GetBlobClient().UploadAsync(stream, options, cancellationToken); return new ValueTask(task); } @@ -99,16 +95,42 @@ protected override ValueTask DeleteCoreAsync(CancellationToken cancellationToken } /// - protected override async ValueTask CopyCoreAsync(string destinationPath, bool overwrite, CancellationToken cancellationToken) + protected override ValueTask CopyCoreAsync(string destinationPath, bool overwrite, CancellationToken cancellationToken) + { + var source = GetBlobClient(); + var destination = _fs.CreateBlobClient(destinationPath); + return CopyBlobAsync(source, destination, overwrite, cancellationToken); + } + + /// + protected override ValueTask CopyToCoreAsync(VirtualFile destination, bool overwrite, CancellationToken cancellationToken) + { + return destination switch + { + AzureFile destinationFile => CopyBlobAsync(GetBlobClient(), destinationFile.GetBlobClient(), overwrite, cancellationToken), + _ => base.CopyToCoreAsync(destination, overwrite, cancellationToken) + }; + } + + /// + /// Asynchronously copies a source blob to the specified destination. + /// + /// The source blob client. + /// The destination blob client. + /// A boolean value indicating whether to overwrite the destination blob if it already exists. + /// A cancellation token to cancel the operation. + /// + /// A representing the asynchronous operation. + /// + private static async ValueTask CopyBlobAsync(BlobClient source, BlobClient destination, bool overwrite, CancellationToken cancellationToken) { - var destClient = _fs.CreateBlobClient(destinationPath); var conditions = !overwrite ? new BlobRequestConditions { IfNoneMatch = new ETag("*") } : null; - var operation = await destClient + var operation = await destination .StartCopyFromUriAsync( - GetBlobClient().Uri, + source.Uri, destinationConditions: conditions, cancellationToken: cancellationToken) .ConfigureAwait(false); @@ -119,23 +141,15 @@ await operation cancellationToken) .ConfigureAwait(false); - var properties = ( - await destClient - .GetPropertiesAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false) - ).Value; + BlobProperties properties = await destination + .GetPropertiesAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); if (properties.CopyStatus != CopyStatus.Success) { var message = $"Error while copying file. {properties.CopyStatus}: {properties.CopyStatusDescription}"; throw new InvalidOperationException(message); } - - await destClient - .SetHttpHeadersAsync( - _fs.GetBlobHeaders(destinationPath), - cancellationToken: cancellationToken) - .ConfigureAwait(false); } /// diff --git a/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs b/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs index 9d987db..9d03a3d 100644 --- a/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs +++ b/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using Azure.Core; +using Azure.Storage; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -18,28 +20,79 @@ public sealed class AzureFileSystem : IVirtualFileSystem public bool IsReadOnly { get; init; } /// - /// Gets the options used to configure the file system. + /// Gets the instance. /// - public AzureFileSystemOptions Options { get; } + internal BlobContainerClient AzureClient { get; } /// - /// Gets the instance. + /// Initializes a new instance of the class. /// - internal BlobContainerClient Container { get; } + /// A connection string includes the authentication information required for your application + /// to access data in an Azure Storage account at runtime. + /// + /// For more information, + /// Configure Azure Storage connection strings + /// + /// The name of the blob container in the storage account to reference. + public AzureFileSystem(string connectionString, string containerName) => + AzureClient = new BlobContainerClient(connectionString, containerName); /// - /// Initializes a new instance of the class using the specified container name and configuration options. + /// Initializes a new instance of the class. /// - /// The name of the container in Azure Blob Storage. - /// The used to configure the file system. - public AzureFileSystem(string containerName, AzureFileSystemOptions options) - { - Options = options; - Container = new BlobContainerClient(options.ConnectionString, containerName); + /// A connection string includes the authentication information required for your application + /// to access data in an Azure Storage account at runtime. + /// + /// For more information, + /// Configure Azure Storage connection strings + /// + /// The name of the blob container in the storage account to reference. + /// Optional client options that define the transport pipeline policies + /// for authentication, retries, etc., that are applied to every request. + public AzureFileSystem(string connectionString, string containerName, BlobClientOptions? options) => + AzureClient = new BlobContainerClient(connectionString, containerName, options); - Container.CreateIfNotExists(); - Container.SetAccessPolicy(options.Public ? PublicAccessType.Blob : PublicAccessType.None); - } + /// + /// Initializes a new instance of the class. + /// + /// A referencing the blob container + /// that includes the name of the account and the name of the container. This is likely to be similar + /// to https://{account_name}.blob.core.windows.net/{container_name}. + /// Optional client options that define the transport pipeline policies + /// for authentication, retries, etc., that are applied to every request. + public AzureFileSystem(Uri containerUri, BlobClientOptions? options = null) => + AzureClient = new BlobContainerClient(containerUri, options); + + /// + /// Initializes a new instance of the class. + /// + /// A referencing the blob container + /// that includes the name of the account and the name of the container. This is likely to be similar + /// to https://{account_name}.blob.core.windows.net/{container_name}. + /// The shared key credential used to sign requests. + /// Optional client options that define the transport pipeline policies + /// for authentication, retries, etc., that are applied to every request. + public AzureFileSystem(Uri containerUri, StorageSharedKeyCredential credential, BlobClientOptions? options = null) => + AzureClient = new BlobContainerClient(containerUri, credential, options); + + /// + /// Initializes a new instance of the class. + /// + /// A referencing the blob container + /// that includes the name of the account and the name of the container. This is likely to be similar + /// to https://{account_name}.blob.core.windows.net/{container_name}. + /// The token credential used to sign requests. + /// Optional client options that define the transport pipeline policies + /// for authentication, retries, etc., that are applied to every request. + public AzureFileSystem(Uri containerUri, TokenCredential credential, BlobClientOptions? options = null) => + AzureClient = new BlobContainerClient(containerUri, credential, options); + + /// + /// Initializes a new instance of the class. + /// + /// The instance used to interact with the Azure Blob storage container. + public AzureFileSystem(BlobContainerClient client) => + AzureClient = client; /// public VirtualDirectory GetDirectory(string path) => @@ -49,6 +102,50 @@ public VirtualDirectory GetDirectory(string path) => public VirtualFile GetFile(string path) => new AzureFile(this, VirtualPath.GetFullPath(path)); + /// + /// Asynchronously creates the container in Azure Blob Storage if it does not already exist. + /// + /// An optional cancellation token to cancel the operation. + /// + /// A representing the asynchronous operation. + /// + public ValueTask CreateContainerAsync(CancellationToken cancellationToken = default) => + CreateContainerAsync(PublicAccessType.None, cancellationToken); + + /// + /// Asynchronously creates the container in Azure Blob Storage if it does not already exist. + /// + /// Specifies whether data in the container may be accessed publicly and the level of access. + /// + /// + /// + /// : Specifies full public read access for both the container and blob data. + /// Clients can enumerate blobs within the container via anonymous requests, but cannot enumerate containers within the storage account. + /// + /// + /// + /// + /// : Specifies public read access for blobs only. Blob data within this container can be + /// read via anonymous requests, but container data is not available. Clients cannot enumerate blobs within the container via anonymous requests. + /// + /// + /// + /// + /// : Specifies that the container data is private to the account owner. + /// + /// + /// + /// + /// An optional cancellation token to cancel the operation. + /// + /// A representing the asynchronous operation. + /// + public ValueTask CreateContainerAsync(PublicAccessType accessType, CancellationToken cancellationToken = default) + { + var task = AzureClient.CreateIfNotExistsAsync(accessType, cancellationToken: cancellationToken); + return new ValueTask(task); + } + /// void IDisposable.Dispose() { @@ -64,20 +161,6 @@ void IDisposable.Dispose() internal BlobClient CreateBlobClient(string path) { Debug.Assert(path == VirtualPath.GetFullPath(path)); - return Container.GetBlobClient(path[1..]); - } - - /// - /// Returns the for the blob located at the specified path. - /// - /// The path to the blob. - /// - /// The associated with the blob at the specified path. - /// - internal BlobHttpHeaders GetBlobHeaders(string path) - { - var headers = new BlobHttpHeaders(); - Options.HeadersUpdate(this, new HeadersUpdateEventArgs(path, headers)); - return headers; + return AzureClient.GetBlobClient(path[1..]); } } diff --git a/src/Ramstack.FileSystem.Azure/AzureFileSystemOptions.cs b/src/Ramstack.FileSystem.Azure/AzureFileSystemOptions.cs deleted file mode 100644 index 62c7a85..0000000 --- a/src/Ramstack.FileSystem.Azure/AzureFileSystemOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Azure.Storage.Blobs.Models; - -namespace Ramstack.FileSystem.Azure; - -/// -/// Represents options for configuring an Azure Blob file system. -/// -public sealed class AzureFileSystemOptions -{ - /// - /// Gets or sets the connection string for Azure Blob storage. - /// - public string? ConnectionString { get; init; } - - /// - /// Gets or sets a value indicating whether blobs should be publicly accessible. - /// - public bool Public { get; init; } - - /// - /// Occurs when blob HTTP headers need to be updated, allowing users to modify headers before AzureBlob is uploaded to Azure. - /// - public event EventHandler? OnHeadersUpdate; - - /// - /// Raises the event, notifying subscribers to update blob HTTP headers. - /// - /// The source of the event. - /// The containing the headers to update. - internal void HeadersUpdate(object sender, HeadersUpdateEventArgs e) => - OnHeadersUpdate?.Invoke(sender, e); -} diff --git a/src/Ramstack.FileSystem.Azure/HeadersUpdateEventArgs.cs b/src/Ramstack.FileSystem.Azure/HeadersUpdateEventArgs.cs deleted file mode 100644 index 5aec5d9..0000000 --- a/src/Ramstack.FileSystem.Azure/HeadersUpdateEventArgs.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Azure.Storage.Blobs.Models; - -namespace Ramstack.FileSystem.Azure; - -/// -/// Provides event arguments containing the path and blob HTTP headers. -/// -/// The path associated with the blob. -/// The blob HTTP headers to update. -public sealed class HeadersUpdateEventArgs(string path, BlobHttpHeaders headers) : EventArgs -{ - /// - /// Gets the path associated with the blob. - /// - public string Path => path; - - /// - /// Gets the blob HTTP headers to update. - /// - public BlobHttpHeaders Headers => headers; -} diff --git a/src/Ramstack.FileSystem.Physical/PhysicalFile.cs b/src/Ramstack.FileSystem.Physical/PhysicalFile.cs index 1ef4b45..9e895e6 100644 --- a/src/Ramstack.FileSystem.Physical/PhysicalFile.cs +++ b/src/Ramstack.FileSystem.Physical/PhysicalFile.cs @@ -43,8 +43,13 @@ internal PhysicalFile(PhysicalFileSystem fileSystem, string path, string physica /// protected override ValueTask OpenReadCoreAsync(CancellationToken cancellationToken) { - const FileOptions Options = FileOptions.Asynchronous | FileOptions.SequentialScan; - var stream = new FileStream(_physicalPath, FileMode.Open, FileAccess.Read, FileShare.Read, DefaultBufferSize, Options); + // SequentialScan is a performance hint that requires extra sys-call on non-Windows systems. + // https://github.com/dotnet/runtime/blob/46c9a4fff83f35ec659e6659050440aadccf3201/src/libraries/System.Private.CoreLib/src/System/IO/File.cs#L694 + var options = Path.DirectorySeparatorChar == '\\' + ? FileOptions.Asynchronous | FileOptions.SequentialScan + : FileOptions.Asynchronous; + + var stream = new FileStream(_physicalPath, FileMode.Open, FileAccess.Read, FileShare.Read, DefaultBufferSize, options); return new ValueTask(stream); } @@ -54,9 +59,11 @@ protected override ValueTask OpenWriteCoreAsync(CancellationToken cancel { EnsureDirectoryExists(); - const FileOptions Options = FileOptions.Asynchronous | FileOptions.SequentialScan; + var options = Path.DirectorySeparatorChar == '\\' + ? FileOptions.Asynchronous | FileOptions.SequentialScan + : FileOptions.Asynchronous; - var stream = new FileStream(_physicalPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, DefaultBufferSize, Options); + var stream = new FileStream(_physicalPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, DefaultBufferSize, options); // Since, FileMode.OpenOrCreate doesn't truncate the file, we manually // set the file length to zero to remove any leftover data. @@ -70,14 +77,16 @@ protected override async ValueTask WriteCoreAsync(Stream stream, bool overwrite, { EnsureDirectoryExists(); - const FileOptions Options = FileOptions.Asynchronous | FileOptions.SequentialScan; + var options = Path.DirectorySeparatorChar == '\\' + ? FileOptions.Asynchronous | FileOptions.SequentialScan + : FileOptions.Asynchronous; // To overwrite the file, we use FileMode.OpenOrCreate instead of FileMode.Create. // This avoids a System.UnauthorizedAccessException: Access to the path is denied, // which can occur if the file has the FileAttributes.Hidden attribute. var fileMode = overwrite ? FileMode.OpenOrCreate : FileMode.CreateNew; - await using var fs = new FileStream(_physicalPath, fileMode, FileAccess.Write, FileShare.None, DefaultBufferSize, Options); + await using var fs = new FileStream(_physicalPath, fileMode, FileAccess.Write, FileShare.None, DefaultBufferSize, options); // Since, FileMode.OpenOrCreate doesn't truncate the file, we manually // set the file length to zero to remove any leftover data. diff --git a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs index ad3003e..91e922f 100644 --- a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs @@ -120,7 +120,7 @@ protected override AmazonS3FileSystem GetFileSystem() { RegionEndpoint = RegionEndpoint.USEast1, ServiceURL = "http://localhost:9000", - ForcePathStyle = true, + ForcePathStyle = true }, bucketName: "storage"); } diff --git a/tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemTests.cs similarity index 64% rename from tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemTests.cs index 82f12f3..1084ff2 100644 --- a/tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Azure.Tests/ReadonlyAzureFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Azure; [TestFixture] [Category("Cloud:Azure")] -public class ReadonlyAzureFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class ReadonlyAzureFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); @@ -14,6 +14,8 @@ public async Task Setup() { using var fs = CreateFileSystem(isReadonly: false); + await fs.CreateContainerAsync(); + foreach (var path in Directory.EnumerateFiles(_storage.Root, "*", SearchOption.AllDirectories)) { await using var stream = File.OpenRead(path); @@ -36,15 +38,11 @@ protected override IVirtualFileSystem GetFileSystem() => protected override DirectoryInfo GetDirectoryInfo() => new DirectoryInfo(_storage.Root); - private static IVirtualFileSystem CreateFileSystem(bool isReadonly) + private static AzureFileSystem CreateFileSystem(bool isReadonly) { - var options = new AzureFileSystemOptions - { - ConnectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;", - Public = false - }; + const string ConnectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"; - return new AzureFileSystem("storage", options) + return new AzureFileSystem(ConnectionString, "storage") { IsReadOnly = isReadonly }; diff --git a/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs similarity index 68% rename from tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs index 0fd7d9d..1b1dff0 100644 --- a/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Azure; [TestFixture] [Category("Cloud:Azure")] -public class WritableAzureFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class WritableAzureFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); @@ -14,6 +14,8 @@ public async Task Setup() { using var fs = GetFileSystem(); + await fs.CreateContainerAsync(); + foreach (var path in Directory.EnumerateFiles(_storage.Root, "*", SearchOption.AllDirectories)) { await using var stream = File.OpenRead(path); @@ -44,23 +46,24 @@ public async Task Directory_BatchDeleting() await fs.WriteFileAsync($"/temp/{i:0000}", Stream.Null); Assert.That( - await fs.GetFilesAsync("/temp", "**").CountAsync(), + await fs.GetFilesAsync("/temp").CountAsync(), Is.EqualTo(Count)); await fs.DeleteDirectoryAsync("/temp"); Assert.That( - await fs.GetFilesAsync("/temp", "**").CountAsync(), + await fs.GetFilesAsync("/temp").CountAsync(), Is.EqualTo(0)); } - protected override IVirtualFileSystem GetFileSystem() + protected override AzureFileSystem GetFileSystem() { - return new AzureFileSystem("storage", new AzureFileSystemOptions + const string ConnectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"; + + return new AzureFileSystem(ConnectionString, "storage") { - ConnectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;", - Public = false - }); + IsReadOnly = false + }; } protected override DirectoryInfo GetDirectoryInfo() => diff --git a/tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterSpecificationTests.cs b/tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterTests.cs similarity index 78% rename from tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterSpecificationTests.cs rename to tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterTests.cs index 7bd118c..7209d70 100644 --- a/tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.FileProvider.Tests/VirtualFileSystemAdapterTests.cs @@ -7,9 +7,9 @@ namespace Ramstack.FileSystem.Adapters; [TestFixture] -public class VirtualFileSystemAdapterSpecificationTests : VirtualFileSystemSpecificationTests +public class VirtualFileSystemAdapterTests : VirtualFileSystemSpecificationTests { - private readonly TempFileStorage _storage = new(); + private readonly TempFileStorage _storage = new TempFileStorage(); [OneTimeTearDown] public void Cleanup() => diff --git a/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs similarity index 90% rename from tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs index f382c35..66a1276 100644 --- a/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Globbing; [TestFixture] -public class GlobingFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class GlobingFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); diff --git a/tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemTests.cs similarity index 93% rename from tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemTests.cs index 321f21b..8ff8e58 100644 --- a/tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Physical.Tests/ReadonlyPhysicalFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Physical; [TestFixture] -public class ReadonlyPhysicalFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class ReadonlyPhysicalFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); diff --git a/tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemTests.cs similarity index 83% rename from tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemTests.cs index f42bef8..ebd0624 100644 --- a/tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Physical.Tests/WriteablePhysicalFileSystemTests.cs @@ -4,7 +4,7 @@ namespace Ramstack.FileSystem.Physical; [TestFixture] -public class WriteablePhysicalFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class WriteablePhysicalFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); diff --git a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs similarity index 84% rename from tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs index 5a71de9..5d451bd 100644 --- a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Prefixed; [TestFixture] -public class PrefixedFileSystemSpecificationTests() : VirtualFileSystemSpecificationTests(Prefix) +public class PrefixedFileSystemTests() : VirtualFileSystemSpecificationTests(Prefix) { private const string Prefix = "solution/app"; diff --git a/tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemTests.cs similarity index 85% rename from tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemTests.cs index fbde606..b402798 100644 --- a/tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Readonly.Tests/ReadOnlyFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Readonly; [TestFixture] -public class ReadOnlyFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class ReadOnlyFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); diff --git a/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs similarity index 85% rename from tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs index 31211f1..d0e343a 100644 --- a/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Sub; [TestFixture] -public class SubFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class SubFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); diff --git a/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs similarity index 87% rename from tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemSpecificationTests.cs rename to tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs index a5b4886..9092723 100644 --- a/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs @@ -6,7 +6,7 @@ namespace Ramstack.FileSystem.Zip; [TestFixture] -public class ZipFileSystemSpecificationTests : VirtualFileSystemSpecificationTests +public class ZipFileSystemTests : VirtualFileSystemSpecificationTests { private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); private readonly TempFileStorage _storage = new();