Public Access
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:
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
|
||||
|
||||
Reference in new issue
Block a user