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
+556
-53
No files matched your search
@@ -352,39 +352,9 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
readClipboard: ClipboardReader(desktop),
|
readClipboard: ClipboardReader(desktop),
|
||||||
updates: updates,
|
updates: updates,
|
||||||
openInEditor: OpenInDefaultEditorAsync,
|
openInEditor: LocalEditor.OpenAsync,
|
||||||
watchFile: WatchFile);
|
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>
|
/// <summary>
|
||||||
/// Watches one file for saves, until the handle is disposed.
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
/// </summary>
|
/// </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.
|
/// have cost.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanWriteFromAnOffset => false;
|
||||||
|
|
||||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(path);
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
|||||||
@@ -879,6 +879,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ObjectStoreRowViewModel? selectedBucket;
|
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>
|
/// <summary>
|
||||||
/// Whether there is a bucket to open at all.
|
/// Whether there is a bucket to open at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string typedPassword = string.Empty;
|
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>
|
/// <remarks>
|
||||||
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
/// 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
|
/// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
|
||||||
|
|
||||||
if (!open.TryBuildConnectionRequest(
|
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;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -2677,9 +2707,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
|
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
|
||||||
|
{
|
||||||
|
plan.Unsafe++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.IsFile)
|
if (row.IsFile)
|
||||||
{
|
{
|
||||||
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
|
plan.Downloads.Add((local, row.Entry));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2695,11 +2731,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
await WalkRemoteAsync(
|
await WalkRemoteAsync(
|
||||||
row.FullPath,
|
row.FullPath,
|
||||||
Path.Combine(LocalPath, row.Name),
|
local,
|
||||||
plan,
|
plan,
|
||||||
cancellationToken).ConfigureAwait(true);
|
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))
|
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -2719,8 +2763,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
plan.Queued++;
|
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>
|
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
||||||
@@ -2774,14 +2816,34 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
switch (entry.Kind)
|
||||||
{
|
{
|
||||||
|
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
|
||||||
|
plan.Unsafe++;
|
||||||
|
break;
|
||||||
|
|
||||||
case SftpEntryKind.File:
|
case SftpEntryKind.File:
|
||||||
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
|
plan.Downloads.Add((here!, entry));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SftpEntryKind.Directory:
|
case SftpEntryKind.Directory:
|
||||||
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
|
pending.Enqueue((entry.FullPath, here!));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SftpEntryKind.SymbolicLink:
|
case SftpEntryKind.SymbolicLink:
|
||||||
@@ -2789,14 +2851,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
|
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
|
||||||
// create, and counting it as a refusal would overstate what went wrong.
|
// counting it as a refusal would overstate what went wrong.
|
||||||
plan.Skipped++;
|
plan.Skipped++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2893,6 +2953,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
||||||
public int Skipped { get; set; }
|
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>
|
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
||||||
public bool Truncated { get; set; }
|
public bool Truncated { get; set; }
|
||||||
|
|
||||||
@@ -2937,6 +3001,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
said += $" {other} not a file.";
|
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)
|
if (plan.Truncated)
|
||||||
{
|
{
|
||||||
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
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}…",
|
$"Fetching {row.Name}…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
var copy = Path.Combine(
|
var folder = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
"dodossh-edits",
|
"dodossh-edits",
|
||||||
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
|
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
|
||||||
row.Name);
|
|
||||||
|
|
||||||
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);
|
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -3922,9 +3997,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RefreshHosts()
|
private void RefreshHosts()
|
||||||
{
|
{
|
||||||
var host = SelectedHost?.EntityId;
|
refreshingPickers = true;
|
||||||
var bucket = SelectedBucket?.EntityId;
|
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
Hosts.Clear();
|
Hosts.Clear();
|
||||||
|
|
||||||
if (vault is not { } open)
|
if (vault is not { } open)
|
||||||
@@ -3937,7 +4013,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
Hosts.Add(row);
|
Hosts.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
|
||||||
|
|
||||||
Buckets.Clear();
|
Buckets.Clear();
|
||||||
|
|
||||||
@@ -3946,7 +4022,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
Buckets.Add(row);
|
Buckets.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
|
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
refreshingPickers = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
/// <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);
|
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));
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (!refreshingPickers)
|
||||||
|
{
|
||||||
|
chosenBucket = value?.EntityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||||
|
|||||||
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
|
|||||||
/// <param name="cancellationToken">Abandons the open.</param>
|
/// <param name="cancellationToken">Abandons the open.</param>
|
||||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
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>
|
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -701,7 +701,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
|
|
||||||
var part = destination + PartSuffix;
|
var part = destination + PartSuffix;
|
||||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
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)
|
if (offset == 0 && existing is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,49 @@ public static class LocalDirectory
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
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>
|
/// <summary>
|
||||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||||
/// </summary>
|
/// </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.
|
/// SFTP fake that a real connect produces.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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]
|
[Fact]
|
||||||
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ internal sealed class FakeSftpSession : ISftpSession
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public int? FailReadAfter { get; set; }
|
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>
|
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
|
/// 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 />
|
/// <inheritdoc />
|
||||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
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)
|
lock (gate)
|
||||||
{
|
{
|
||||||
var existing = files.GetValueOrDefault(path, []);
|
var existing = files.GetValueOrDefault(path, []);
|
||||||
|
var failAfter = FailWriteAfter;
|
||||||
|
FailWriteAfter = null;
|
||||||
|
|
||||||
return Task.FromResult<Stream>(new CommittingStream(
|
return Task.FromResult<Stream>(new CommittingStream(
|
||||||
|
failAfter,
|
||||||
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
||||||
content =>
|
content =>
|
||||||
{
|
{
|
||||||
@@ -300,10 +317,42 @@ internal sealed class FakeSftpSession : ISftpSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A write that lands on the fake host when it is disposed.</summary>
|
/// <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;
|
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()
|
public override void Close()
|
||||||
{
|
{
|
||||||
if (!committed)
|
if (!committed)
|
||||||
|
|||||||
@@ -145,6 +145,37 @@ public sealed class FileTransferQueueTests : IDisposable
|
|||||||
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
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]
|
[Fact]
|
||||||
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
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");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user