Public Access
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot be a file there is left on the host and reported. - EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on macOS, and refuses a .desktop launcher on Linux. - A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue starts those over. - The SFTP host picker re-found its selection from the empty moment of every vault reload and landed on the first host once a minute; the chosen host is now remembered, and a typed password is only sent to the host it was typed for.
497 lines
19 KiB
C#
497 lines
19 KiB
C#
using Amazon.S3;
|
|
using Amazon.S3.Model;
|
|
using Amazon.S3.Transfer;
|
|
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.ObjectStore;
|
|
|
|
/// <summary>
|
|
/// One S3-compatible bucket, as a place with files in it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A bucket is not a filesystem, and the three places that matter are documented on the members rather
|
|
/// than smoothed over.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Listings are one page.</b> <c>ListObjectsV2</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class S3FileStore : IRemoteFileStore
|
|
{
|
|
/// <summary>
|
|
/// The most keys one listing asks for.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
|
|
/// not possible.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Always true, because there is no connection to be up.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public bool IsConnected => Volatile.Read(ref disposed) == 0;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
|
|
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
|
|
/// </remarks>
|
|
public string? TransportFailure => null;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
|
|
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> 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.
|
|
/// </remarks>
|
|
#pragma warning disable CS0067 // The event is never used
|
|
public event EventHandler? ConnectionLost;
|
|
#pragma warning restore CS0067
|
|
|
|
/// <inheritdoc />
|
|
public string HomeDirectory => ObjectKeys.Root;
|
|
|
|
/// <summary>
|
|
/// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<SftpEntry>> 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);
|
|
}
|
|
|
|
/// <summary>Turns one listing into rows a file browser can show.</summary>
|
|
/// <remarks>
|
|
/// Directories first and then by name, which is the order every caller of this interface expects and
|
|
/// what saves the screen sorting it again.
|
|
/// </remarks>
|
|
private static IReadOnlyList<SftpEntry> Project(ListObjectsV2Response response, string prefix)
|
|
{
|
|
var entries = new List<SftpEntry>();
|
|
|
|
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),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// The SDK's timestamp as an unambiguous instant.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Stated rather than converted implicitly. S3 returns <c>Last-Modified</c> in UTC and the SDK hands it
|
|
/// over as a <see cref="DateTime"/> whose <c>Kind</c> 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.
|
|
/// </remarks>
|
|
private static DateTimeOffset Utc(DateTime? moment) =>
|
|
moment is { } value
|
|
? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
|
|
: default;
|
|
|
|
/// <summary>
|
|
/// What one path is, or null when nothing is there.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public async Task<SftpEntry?> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Stream> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens an object for writing, from the beginning.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.</b>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <inheritdoc />
|
|
public bool CanWriteFromAnOffset => false;
|
|
|
|
public Task<Stream> 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<Stream>(
|
|
new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the marker object that makes an empty prefix visible.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A zero-byte object whose key ends in <c>/</c>, 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes one object, or an empty prefix's marker.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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.");
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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.");
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref disposed, 1) == 0)
|
|
{
|
|
client.Dispose();
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// What went wrong, in words that name the bucket rather than the protocol.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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,
|
|
};
|
|
}
|