Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried

- 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.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:30 +02:00
1 parent 4c7f47c026
commit 03c8e8589a
12 files changed
+586 -83

No files matched your search

@@ -145,6 +145,37 @@ public sealed class FileTransferQueueTests : IDisposable
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
}
/// <summary>
/// A bucket writes an object whole, so a retried upload starts again rather than resuming.
/// </summary>
/// <remarks>
/// The failed attempt leaves a part object behind — a bucket keeps what was sent when the stream closes —
/// and the retry used to ask for the offset that part had reached. A bucket refuses any offset, so every
/// retry failed until the row was discarded, under a message saying it would start again.
/// </remarks>
[Fact]
public async Task ARetriedUploadToAStoreThatCannotResume_StartsAgainAndLands()
{
var content = Bytes(300_000);
await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);
host.CanWriteFromAnOffset = false;
host.FailWriteAfter = 100_000;
await using var queue = NewQueue();
var broken = await RunToCompletionAsync(
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
broken.State.ShouldBe(TransferState.Failed);
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeTrue("what a bucket keeps");
var retried = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));
retried.State.ShouldBe(TransferState.Completed, retried.Failure);
host.Read(Remote("artefact.tar")).ShouldBe(content);
}
[Fact]
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
{