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

+1 -31
View File
@@ -352,39 +352,9 @@ internal sealed partial class DodoSshApp : Application
copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
openInEditor: LocalEditor.OpenAsync,
watchFile: WatchFile);
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
@@ -0,0 +1,126 @@
using System.Diagnostics;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Opens a local copy of a remote file for editing, without running it.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
/// depending on which desktop this is. The shell decides <em>when</em>; see
/// <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
/// which is the default text editor whatever the type. Everything else still goes to its association, which
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
/// </para>
/// <para>
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
/// by every mainstream desktop's file associations in an editor, not run.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
internal static class LocalEditor
{
/// <summary>
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
/// </summary>
/// <remarks>
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
/// items, and the script hosts that installers do not register there.
/// </remarks>
private static readonly string[] WindowsRunsThese =
[
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
];
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
private static readonly string[] MacRunsThese =
[
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
".tool", ".webloc", ".workflow",
];
/// <summary>Opens <paramref name="path"/> for editing.</summary>
/// <exception cref="InvalidOperationException">
/// This desktop would run the file and has no editor to offer in its place.
/// </exception>
internal static Task OpenAsync(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var opened = Process.Start(StartInfoFor(path));
return Task.CompletedTask;
}
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
internal static ProcessStartInfo StartInfoFor(string path)
{
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
{
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
{
return edit;
}
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
}
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
{
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
}
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
{
throw new InvalidOperationException(
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
+ "Download it and open it in an editor instead.");
}
return new ProcessStartInfo(path) { UseShellExecute = true };
}
/// <summary>Whether Windows' default action for this file is to run it.</summary>
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
{
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
}
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
/// <remarks>
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
/// <c>run.bat</c> by the time anything acts on it.
/// </remarks>
private static bool HasExtension(string path, IEnumerable<string> extensions)
{
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
return extension.Length > 0
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
/// have cost.
/// </para>
/// </remarks>
/// <inheritdoc />
public bool CanWriteFromAnOffset => false;
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
@@ -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
+8
View File
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <param name="cancellationToken">Abandons the open.</param>
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
/// <remarks>
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
/// </remarks>
bool CanWriteFromAnOffset => true;
/// <summary>Creates one directory, whose parent must exist.</summary>
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
@@ -701,7 +701,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
var part = destination + PartSuffix;
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
var offset = ResumableLength(entry, existing);
// A bucket writes an object whole, so a part left by a failed attempt is started over rather than
// resumed — and replaced, since writing an object replaces it.
var offset = session.CanWriteFromAnOffset ? ResumableLength(entry, existing) : 0;
if (offset == 0 && existing is not null)
{
@@ -94,6 +94,49 @@ public static class LocalDirectory
/// </remarks>
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
/// <summary>
/// Where a file another machine named should land inside <paramref name="directory"/>, or null when that
/// name cannot be a file directly inside it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The name is the remote's, and the remote is not trusted to have chosen one that stays put.</b>
/// A POSIX file name may contain a backslash, a colon or a leading drive letter, and on Windows any of
/// those turns <c>Path.Combine(directory, name)</c> into somewhere else: <c>..\..\AppData\…\Startup\x.bat</c>
/// climbs out, and <c>C:\…</c> is rooted, so <see cref="Path.Combine(string, string)"/> drops the
/// directory altogether. An S3 key is worse, because <c>../</c> is an ordinary prefix there on every
/// platform. Whoever can write a name into a shared directory or bucket would otherwise choose where a
/// download writes on this machine.
/// </para>
/// <para>
/// Characters this platform cannot put in a file name are replaced rather than refused, so a Linux file
/// called <c>backup:2026-10-02</c> still downloads to Windows, as <c>backup_2026-10-02</c>. What is
/// refused is a name that is nothing but a reference to here or above. The final check resolves the path
/// and requires its parent to be the directory, which also catches what Windows does to a trailing dot
/// or space — <c>.. </c> is <c>..</c> by the time it reaches the disk.
/// </para>
/// </remarks>
public static string? ChildPath(string directory, string name)
{
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(name);
var cleaned = string.Join('_', name.Split(Path.GetInvalidFileNameChars()));
if (cleaned.Trim() is "" or "." or "..")
{
return null;
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var child = Path.GetFullPath(Path.Combine(root, cleaned));
return string.Equals(Path.GetDirectoryName(child), root, StringComparison.Ordinal)
&& !string.Equals(child, root, StringComparison.Ordinal)
? child
: null;
}
/// <summary>
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
/// </summary>