using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Transfer; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.ObjectStore; /// /// One S3-compatible bucket, as a place with files in it. /// /// /// /// A bucket is not a filesystem, and the three places that matter are documented on the members rather /// than smoothed over. There are no directories, only keys with slashes in them; an object cannot be /// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each /// is refused with a reason or implemented with its cost stated, because a file browser that quietly did /// something adjacent would be worse than one that said no. /// /// /// Listings are one page. ListObjectsV2 returns up to a thousand keys and this asks for one /// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging /// the whole way through a bucket with a million objects under one prefix is a request storm behind a /// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory /// anybody browses. /// /// internal sealed class S3FileStore : IRemoteFileStore { /// /// The most keys one listing asks for. /// /// /// The service's own maximum. Asking for less would page more often for no benefit; asking for more is /// not possible. /// private const int PageSize = 1000; private readonly IAmazonS3 client; private readonly string bucket; private int disposed; internal S3FileStore(IAmazonS3 client, string bucket) { this.client = client; this.bucket = bucket; } /// /// Always true, because there is no connection to be up. /// /// /// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering /// false when the network is down would be a claim this type cannot make without a request of its own, /// and every operation already reports its own failure. /// public bool IsConnected => Volatile.Read(ref disposed) == 0; /// /// /// Null forever, for the reason is always true: there is no connection under /// this to be lost. A request that fails reports itself, and nothing here outlives one. /// public string? TransportFailure => null; /// /// /// Never raised; see . Declared rather than left off the type because the /// screen holds whichever store it opened as an IRemoteFileStore and subscribes without asking /// which kind it got — an event that is simply never raised is what makes that honest rather than a /// cast and a branch. /// #pragma warning disable CS0067 // The event is never used public event EventHandler? ConnectionLost; #pragma warning restore CS0067 /// public string HomeDirectory => ObjectKeys.Root; /// /// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files. /// /// /// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a /// listing of the root returns every object in the bucket. Common prefixes come back as directories; /// the marker object some tools write for a "folder" is dropped, because it is the directory itself and /// showing it would put an empty-named row inside every one. /// public async Task> ListAsync(string path, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); var prefix = ObjectKeys.ToPrefix(path); ListObjectsV2Response response; try { response = await client.ListObjectsV2Async( new ListObjectsV2Request { BucketName = bucket, Prefix = prefix, Delimiter = "/", MaxKeys = PageSize, }, cancellationToken).ConfigureAwait(false); } catch (AmazonS3Exception exception) { throw new SftpPathException(path, Describe(exception), exception); } return Project(response, prefix); } /// Turns one listing into rows a file browser can show. /// /// Directories first and then by name, which is the order every caller of this interface expects and /// what saves the screen sorting it again. /// private static IReadOnlyList Project(ListObjectsV2Response response, string prefix) { var entries = new List(); foreach (var common in response.CommonPrefixes ?? []) { entries.Add(new SftpEntry( ObjectKeys.NameOf(common), ObjectKeys.ToPath(common), SftpEntryKind.Directory, Length: 0, LastWriteTimeUtc: default, // Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a // prefix would be a fact this store made up. Permissions: string.Empty)); } foreach (var item in response.S3Objects ?? []) { // The marker object for this prefix itself, which several tools write to make a folder appear // in a web console. It is this directory, not something in it. if (string.Equals(item.Key, prefix, StringComparison.Ordinal)) { continue; } entries.Add(new SftpEntry( ObjectKeys.NameOf(item.Key), ObjectKeys.ToPath(item.Key), SftpEntryKind.File, item.Size ?? 0, Utc(item.LastModified), Permissions: string.Empty)); } return [ .. entries .OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory) .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase), ]; } /// /// The SDK's timestamp as an unambiguous instant. /// /// /// Stated rather than converted implicitly. S3 returns Last-Modified in UTC and the SDK hands it /// over as a whose Kind is not reliably set — so an implicit conversion /// would read it as local time on some paths and shift every timestamp in the listing by the machine's /// offset. The file browser shows this column beside an SFTP one. /// private static DateTimeOffset Utc(DateTime? moment) => moment is { } value ? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc)) : default; /// /// What one path is, or null when nothing is there. /// /// /// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a /// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether /// anything lives under it as a prefix. The order matters — a key can be both, and the object is the /// more specific answer. /// public async Task StatAsync(string path, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); var key = ObjectKeys.ToKey(path); if (key.Length == 0) { return new SftpEntry( string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty); } try { var head = await client.GetObjectMetadataAsync( new GetObjectMetadataRequest { BucketName = bucket, Key = key }, cancellationToken).ConfigureAwait(false); return new SftpEntry( ObjectKeys.NameOf(key), ObjectKeys.ToPath(key), SftpEntryKind.File, head.ContentLength, Utc(head.LastModified), Permissions: string.Empty); } catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) { // Not an object. It may still be a prefix with things under it, which is what a browser means // by a directory. } var listing = await client.ListObjectsV2Async( new ListObjectsV2Request { BucketName = bucket, Prefix = ObjectKeys.ToPrefix(path), MaxKeys = 1, }, cancellationToken).ConfigureAwait(false); return listing.KeyCount > 0 ? new SftpEntry( ObjectKeys.NameOf(key), ObjectKeys.ToPath(key), SftpEntryKind.Directory, 0, default, string.Empty) : null; } /// public async Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); ArgumentOutOfRangeException.ThrowIfNegative(offset); var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) }; if (offset > 0) { // A ranged GET, which is what makes an interrupted download resumable — and the one place where // a bucket is better at this than SFTP, because the range is part of the protocol rather than a // seek on an open handle. request.ByteRange = new ByteRange(offset, long.MaxValue); } try { var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false); return response.ResponseStream; } catch (AmazonS3Exception exception) { throw new SftpPathException(path, Describe(exception), exception); } } /// /// Opens an object for writing, from the beginning. /// /// /// /// A non-zero offset is refused, and this is the one capability a bucket genuinely does not have. /// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart /// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag /// across the interruption — state this store would have to persist somewhere, on behalf of a queue that /// already has its own idea of what resuming means. Refusing with a reason is the honest answer; /// silently starting from zero would corrupt a resumed file. /// /// /// The returned stream is the writing half of a pipe. A background upload reads the other half and /// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part /// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would /// have cost. /// /// /// public bool CanWriteFromAnOffset => false; public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); if (offset != 0) { throw new SftpPathException( path, "An object cannot be written to from the middle, so an interrupted upload to a bucket " + "starts again rather than resuming."); } return Task.FromResult( new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken)); } /// /// Creates the marker object that makes an empty prefix visible. /// /// /// A zero-byte object whose key ends in /, which is the convention every S3 console and most /// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by /// itself once real objects live under the prefix, which is why the listing above drops it. /// public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); var prefix = ObjectKeys.ToPrefix(path); if (prefix.Length == 0) { throw new SftpPathException(path, "The root of a bucket already exists."); } try { await client.PutObjectAsync( new PutObjectRequest { BucketName = bucket, Key = prefix, ContentBody = string.Empty, }, cancellationToken).ConfigureAwait(false); } catch (AmazonS3Exception exception) { throw new SftpPathException(path, Describe(exception), exception); } } /// /// Deletes one object, or an empty prefix's marker. /// /// /// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete /// against a bucket is the one operation on this screen that can destroy something no undo reaches. A /// prefix with anything under it is refused and says so. /// public async Task DeleteAsync(string path, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(path); var key = ObjectKeys.ToKey(path); if (key.Length == 0) { throw new SftpPathException(path, "A bucket cannot delete its own root."); } if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory }) { var listing = await client.ListObjectsV2Async( new ListObjectsV2Request { BucketName = bucket, Prefix = ObjectKeys.ToPrefix(path), MaxKeys = 2, }, cancellationToken).ConfigureAwait(false); // One key is the marker object for this prefix itself; anything more is contents. if (listing.KeyCount > 1) { throw new SftpPathException( path, "There are still objects under this prefix, so it was not deleted."); } key = ObjectKeys.ToPrefix(path); } try { await client.DeleteObjectAsync( new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken).ConfigureAwait(false); } catch (AmazonS3Exception exception) { throw new SftpPathException(path, Describe(exception), exception); } } /// /// Copies to the new key and deletes the old one, which is what a bucket has instead of rename. /// /// /// /// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both /// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is /// real and a failure leaves a duplicate rather than a loss, which is the safe direction. /// /// /// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing /// a rename's clothing, and the failure mode is a half-moved directory. /// /// /// /// /// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not /// nine bits and do not map onto them — writing something plausible here would put a mode in a listing /// that no request would ever be decided by. /// public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) => throw new SftpPathException( path, "An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set."); /// /// /// Refused for the reason above: an object's timestamp is when the service stored it, and there is no /// request that says otherwise. A copy that claimed the source's time would be a date the bucket does /// not agree with. /// public Task SetLastWriteTimeAsync( string path, DateTimeOffset lastWriteTimeUtc, CancellationToken cancellationToken) => throw new SftpPathException( path, "An object's timestamp is when the bucket stored it, and cannot be set to anything else."); /// public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fromPath); ArgumentNullException.ThrowIfNull(toPath); if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false) is not { Kind: SftpEntryKind.File }) { throw new SftpPathException( fromPath, "Only an object can be renamed in a bucket. A prefix would have to be copied key by key."); } try { await client.CopyObjectAsync( new CopyObjectRequest { SourceBucket = bucket, SourceKey = ObjectKeys.ToKey(fromPath), DestinationBucket = bucket, DestinationKey = ObjectKeys.ToKey(toPath), }, cancellationToken).ConfigureAwait(false); await client.DeleteObjectAsync( new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) }, cancellationToken).ConfigureAwait(false); } catch (AmazonS3Exception exception) { throw new SftpPathException(fromPath, Describe(exception), exception); } } /// public ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 0) { client.Dispose(); } return ValueTask.CompletedTask; } /// /// What went wrong, in words that name the bucket rather than the protocol. /// /// /// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not /// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the /// moment to say which of the two is more likely. /// private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch { System.Net.HttpStatusCode.NotFound => "There is nothing at that key.", System.Net.HttpStatusCode.Forbidden => "The bucket refused that. Check the access key and what it is allowed to do.", System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" => "The bucket is in a different region to the one configured.", _ => exception.Message, }; }