Public Access
- 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.
127 lines
6.0 KiB
C#
127 lines
6.0 KiB
C#
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);
|
|
}
|
|
}
|