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>
@@ -0,0 +1,70 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>LocalEditor</c>: a remote file opened for editing is never run.
/// </summary>
/// <remarks>
/// The file's name was chosen by whoever can write to the host, so the question for every name here is
/// whether the desktop's default action would execute it. Only the Windows rule is exercised directly — it
/// is a pure function of the name and <c>PATHEXT</c> — while what each platform starts is checked on the
/// platform the suite is running on.
/// </remarks>
public sealed class LocalEditorTests
{
[Theory]
[InlineData("deploy.bat")]
[InlineData("DEPLOY.CMD")]
[InlineData("notes.hta")]
[InlineData("setup.js")]
[InlineData("link.lnk")]
[InlineData("run.ps1")]
[InlineData("settings.reg")]
[InlineData("deploy.bat.")]
[InlineData("deploy.bat ")]
public void AFileWindowsWouldRun_IsRecognised(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeTrue();
[Theory]
[InlineData("nginx.conf")]
[InlineData("authorized_keys")]
[InlineData(".bashrc")]
[InlineData("app.json")]
[InlineData("bat")]
public void AnOrdinaryConfigFile_IsLeftToItsAssociation(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeFalse();
[Fact]
public void WhateverPathExtAdds_IsTreatedAsRunnableToo() =>
LocalEditor.WouldRunOnWindows("tool.foo", pathExt: ".COM;.EXE;.FOO").ShouldBeTrue();
[Fact]
public void ARunnableFile_IsNeverHandedToTheDefaultOpenAction()
{
var path = Path.Combine(Path.GetTempPath(), "deploy.bat");
if (OperatingSystem.IsLinux())
{
// No portable editor to name, and a .bat is not something a Linux desktop runs.
LocalEditor.StartInfoFor(path).UseShellExecute.ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => LocalEditor.StartInfoFor("/tmp/app.desktop"));
return;
}
var start = LocalEditor.StartInfoFor(OperatingSystem.IsMacOS() ? "/tmp/run.command" : path);
// Either the type's own "edit" verb, or a named editor with the file as an argument — never the
// file itself under its default verb.
(start.Verb == "edit" || !start.UseShellExecute).ShouldBeTrue();
}
[Fact]
public void AnOrdinaryFile_StillOpensWithItsAssociation()
{
var start = LocalEditor.StartInfoFor(Path.Combine(Path.GetTempPath(), "nginx.conf"));
start.UseShellExecute.ShouldBeTrue();
start.Verb.ShouldBeEmpty();
}
}
@@ -131,6 +131,35 @@ public sealed class TransferQueueingTests : IDisposable
/// SFTP fake that a real connect produces.
/// </para>
/// </remarks>
/// <summary>
/// A name the host chose cannot choose where on this machine the download lands.
/// </summary>
/// <remarks>
/// Backslashes and colons are ordinary in a POSIX file name and are separators and drive letters on
/// Windows, and a slash arrives in a name from an S3 key — so every one of these would have escaped the
/// folder through <c>Path.Combine</c> somewhere. They are queued inside it under a cleaned name, and the
/// bare <c>..</c>, which no cleaning can make a file name, is left on the host and said so.
/// </remarks>
[Fact]
public void ADownloadNamedToEscapeTheFolder_LandsInsideItOrNotAtAll()
{
Connected();
transfers.QueueDownloads(
[
RemoteFile(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat"),
RemoteFile(@"C:\Windows\evil.dll"),
RemoteFile("../../escaped.txt"),
RemoteFile(".."),
]);
var landed = Queued().Select(row => row.Transfer.LocalPath).ToList();
landed.Count.ShouldBe(3);
landed.ShouldAllBe(path => Path.GetDirectoryName(path) == directory);
transfers.Status.ShouldContain("1 name points outside the folder");
}
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
{
@@ -93,6 +93,15 @@ internal sealed class FakeSftpSession : ISftpSession
/// </remarks>
public int? FailReadAfter { get; set; }
/// <summary>
/// Whether a write may start anywhere but zero. False makes this behave like a bucket, which refuses an
/// offset — and, like a bucket, still keeps whatever an abandoned write had sent when it is closed.
/// </summary>
public bool CanWriteFromAnOffset { get; set; } = true;
/// <summary>Fails the next write after this many bytes, once, the way a dropped upload does.</summary>
public int? FailWriteAfter { get; set; }
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
/// <remarks>
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
@@ -195,11 +204,19 @@ internal sealed class FakeSftpSession : ISftpSession
/// <inheritdoc />
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
if (!CanWriteFromAnOffset && offset != 0)
{
throw new SftpPathException(path, "An object cannot be written to from the middle.");
}
lock (gate)
{
var existing = files.GetValueOrDefault(path, []);
var failAfter = FailWriteAfter;
FailWriteAfter = null;
return Task.FromResult<Stream>(new CommittingStream(
failAfter,
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
content =>
{
@@ -300,10 +317,42 @@ internal sealed class FakeSftpSession : ISftpSession
}
/// <summary>A write that lands on the fake host when it is disposed.</summary>
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
private sealed class CommittingStream(int? failAfter, byte[] prefix, Action<byte[]> commit) : MemoryStream()
{
private bool committed;
public override void Write(ReadOnlySpan<byte> buffer)
{
FailIfDue(buffer.Length);
base.Write(buffer);
}
public override void Write(byte[] buffer, int offset, int count)
{
FailIfDue(count);
base.Write(buffer, offset, count);
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
FailIfDue(buffer.Length);
return base.WriteAsync(buffer, cancellationToken);
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
FailIfDue(count);
return base.WriteAsync(buffer, offset, count, cancellationToken);
}
private void FailIfDue(int count)
{
if (failAfter is { } limit && Length + count > limit)
{
throw new IOException("The connection dropped mid-upload.");
}
}
public override void Close()
{
if (!committed)
@@ -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()
{
@@ -0,0 +1,68 @@
namespace DodoSSH.Client.Transfer.Tests;
/// <summary>
/// <c>LocalDirectory.ChildPath</c>: where a name another machine chose may land on this one.
/// </summary>
/// <remarks>
/// The assertion that matters is the same in every row — the result's parent is the directory asked about,
/// or there is no result — because that is the property a download relies on, and it is checked against the
/// resolved path rather than the string so that what the file system does to a name counts too.
/// </remarks>
public sealed class LocalDirectoryTests
{
private static readonly string Directory = Path.Combine(Path.GetTempPath(), "dodossh-child-path");
[Theory]
[InlineData("notes.txt")]
[InlineData("backup:2026-10-02")]
[InlineData(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat")]
[InlineData(@"C:\Windows\evil.dll")]
[InlineData("../../escaped.txt")]
[InlineData("/etc/passwd")]
[InlineData("...")]
[InlineData(".hidden")]
public void ANameThatCanBeAFileHere_LandsDirectlyInsideTheDirectory(string name)
{
var child = LocalDirectory.ChildPath(Directory, name);
if (child is null)
{
// Only acceptable for "...", which Windows reduces to nothing; everywhere else it is a name.
OperatingSystem.IsWindows().ShouldBeTrue($"'{name}' was refused");
name.ShouldBe("...");
return;
}
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(".")]
[InlineData("..")]
public void ANameThatIsOnlyHereOrAbove_IsRefused(string name) =>
LocalDirectory.ChildPath(Directory, name).ShouldBeNull();
/// <summary>
/// Windows drops a trailing dot or space from a path component, so <c>.. </c> is <c>..</c> by the time
/// it reaches the disk — which a check of the string alone would have let through.
/// </summary>
[Fact]
public void ATrailingSpaceOrDot_DoesNotSmuggleAParentReferencePastTheCheck()
{
foreach (var name in new[] { ".. ", "...", ".. .", ". " })
{
var child = LocalDirectory.ChildPath(Directory, name);
if (child is not null)
{
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory), name);
}
}
}
[Fact]
public void AnOrdinaryName_IsKeptAsItIs() =>
Path.GetFileName(LocalDirectory.ChildPath(Directory, "nginx.conf")).ShouldBe("nginx.conf");
}