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

@@ -879,6 +879,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
/// <remarks>
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
/// only when a person changes the picker, and is what every refresh looks for.
/// </remarks>
private Guid? chosenHost;
/// <inheritdoc cref="chosenHost" path="/remarks" />
private Guid? chosenBucket;
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
private bool refreshingPickers;
/// <summary>
/// Whether there is a bucket to open at all.
/// </summary>
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private string typedPassword = string.Empty;
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
/// <remarks>
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
/// host it was not typed for.
/// </remarks>
private Guid? typedPasswordFor;
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
/// <remarks>
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
@@ -1780,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
if (!open.TryBuildConnectionRequest(
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
row.Host, password, out var request, out var identityLabel, out var refusal))
{
Status = refusal;
return;
@@ -2677,9 +2707,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
foreach (var row in rows)
{
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
{
plan.Unsafe++;
continue;
}
if (row.IsFile)
{
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
plan.Downloads.Add((local, row.Entry));
continue;
}
@@ -2695,11 +2731,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await WalkRemoteAsync(
row.FullPath,
Path.Combine(LocalPath, row.Name),
local,
plan,
cancellationToken).ConfigureAwait(true);
}
EnqueueDownloads(plan);
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
private void EnqueueDownloads(TransferPlan plan)
{
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
{
try
@@ -2719,8 +2763,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
plan.Queued++;
}
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
@@ -2774,30 +2816,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
switch (entry.Kind)
{
case SftpEntryKind.File:
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
// create, and counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
PlanRemoteEntry(plan, pending, entry, toDirectory);
}
}
}
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
private static void PlanRemoteEntry(
TransferPlan plan,
Queue<(string Remote, string Local)> pending,
SftpEntry entry,
string toDirectory)
{
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
? LocalDirectory.ChildPath(toDirectory, entry.Name)
: null;
switch (entry.Kind)
{
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
plan.Unsafe++;
break;
case SftpEntryKind.File:
plan.Downloads.Add((here!, entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, here!));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
// counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
}
/// <summary>What an upload of this local file should carry to the host with it.</summary>
/// <remarks>
/// <para>
@@ -2893,6 +2953,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
public int Skipped { get; set; }
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
public int Unsafe { get; set; }
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
public bool Truncated { get; set; }
@@ -2937,6 +3001,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
said += $" {other} not a file.";
}
if (plan.Unsafe > 0)
{
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
said += $" {named} left on the host.";
}
if (plan.Truncated)
{
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
@@ -3162,13 +3232,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
$"Fetching {row.Name}…",
async () =>
{
var copy = Path.Combine(
var folder = Path.Combine(
Path.GetTempPath(),
"dodossh-edits",
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
row.Name);
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
Directory.CreateDirectory(Path.GetDirectoryName(copy)!);
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
{
Status = $"{row.Name} is not a name this machine can open as a file.";
return;
}
Directory.CreateDirectory(folder);
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
@@ -3922,31 +3997,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
private void RefreshHosts()
{
var host = SelectedHost?.EntityId;
var bucket = SelectedBucket?.EntityId;
refreshingPickers = true;
Hosts.Clear();
if (vault is not { } open)
try
{
return;
}
Hosts.Clear();
foreach (var row in open.Hosts)
if (vault is not { } open)
{
return;
}
foreach (var row in open.Hosts)
{
Hosts.Add(row);
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
finally
{
Hosts.Add(row);
refreshingPickers = false;
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
@@ -4106,8 +4187,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await SettleRemoteListingAsync().ConfigureAwait(true);
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
partial void OnSelectedHostChanged(HostRowViewModel? value)
{
if (!refreshingPickers)
{
chosenHost = value?.EntityId;
// A person moving to another host gets an empty box rather than one still showing a password
// that, by typedPasswordFor, would no longer be sent.
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
{
TypedPassword = string.Empty;
}
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
{
if (!refreshingPickers)
{
chosenBucket = value?.EntityId;
}
}
/// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for