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,
};
}