diff --git a/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs
index 6be72ec..a275d13 100644
--- a/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs
@@ -37,6 +37,12 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
/// The mode as drwxr-xr-x, which is the design's PERMS column.
internal string Permissions => entry.Permissions;
+
+ /// Whether the row is a file with an execute bit, which the NAME column colours for.
+ internal bool IsExecutable => entry.IsExecutable;
+
+ /// Whether the row is a file anyone may write to, which the PERMS column colours for.
+ internal bool IsWorldWritable => entry.IsWorldWritable;
}
/// One local file or directory, as a row.
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
index 72cd513..6408529 100644
--- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
@@ -23,6 +23,16 @@
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
described as deliberately rare. This is the one place it is spent.
+
+ Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
+ what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
+ "live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
+ characters that actually say so. The two never compete for one TextBlock, which is what lets a
+ world-writable executable show both facts instead of one winning an argument.
+
+ Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
+ Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
+ string is the compromise: the eye lands on the column, and the string itself is the detail.
-->
+
+
+
+
+
+
@@ -291,14 +320,15 @@
-
+
diff --git a/src/DodoSSH.Client.Ssh/SftpSession.cs b/src/DodoSSH.Client.Ssh/SftpSession.cs
index 6bff153..9f802cc 100644
--- a/src/DodoSSH.Client.Ssh/SftpSession.cs
+++ b/src/DodoSSH.Client.Ssh/SftpSession.cs
@@ -51,6 +51,32 @@ public sealed record SftpEntry(
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
///
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
+
+ /// Whether this is a file somebody can run.
+ ///
+ /// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
+ /// directory on a host — a listing that marked them all would be marking nothing.
+ ///
+ public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);
+
+ ///
+ /// Whether this is a file any account on the host may write to.
+ ///
+ ///
+ ///
+ /// Files only, and for two separate reasons. A symbolic link is lrwxrwxrwx by convention on every
+ /// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
+ /// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
+ /// /tmp, made safe by the sticky bit — which does not render, so flagging
+ /// the directory would be warning about the half of the mode that is on screen while the half that
+ /// answers the warning is not.
+ ///
+ ///
+ /// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
+ /// would want to have noticed.
+ ///
+ ///
+ public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
}
///
@@ -106,6 +132,27 @@ public static class PosixMode
triple[2] = execute ? 'x' : '-';
}
}
+
+ /// Whether any of the three execute bits is set.
+ public static bool HasAnyExecuteBit(string mode) =>
+ At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';
+
+ /// Whether the others triple carries the write bit.
+ public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
+
+ private const int OwnerExecute = 3;
+ private const int GroupExecute = 6;
+ private const int OthersWrite = 8;
+ private const int OthersExecute = 9;
+
+ ///
+ /// Reading back what wrote, rather than carrying the nine booleans through
+ /// as well. The alternative is a second representation of one fact, and the two
+ /// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
+ /// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
+ /// a colour, and a listing is not worth failing over one.
+ ///
+ private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
}
///
diff --git a/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs b/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs
index bed0101..2035349 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs
@@ -83,6 +83,69 @@ public sealed class RemotePathTests
.ShouldBe("lrwxrwxrwx");
}
+ [Theory]
+ [InlineData("-rwxr-xr-x", true)]
+ [InlineData("-rw-r-xr--", true)]
+ [InlineData("-rw-r--r-x", true)]
+ [InlineData("-rw-r--r--", false)]
+ [InlineData("----------", false)]
+ public void PosixMode_ReadsAnExecuteBitInAnyTriple(string mode, bool expected)
+ {
+ // Any of the three, not the owner's: the account browsing is not necessarily the owner, and a file
+ // that only the group may run is still a file somebody runs.
+ PosixMode.HasAnyExecuteBit(mode).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("-rw-rw-rw-", true)]
+ [InlineData("-rw-rw-r--", false)]
+ [InlineData("-rw-r--r--", false)]
+ [InlineData("--------w-", true)]
+ public void PosixMode_ReadsTheOthersWriteBit(string mode, bool expected)
+ {
+ // The others triple only. A group-writable file is writable by a named set of people, which is what
+ // groups are for; this asks about the column that names nobody.
+ PosixMode.IsWorldWritable(mode).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("rwxrwxrwx")]
+ [InlineData("?")]
+ public void PosixMode_AnswersFalseForAModeItDidNotWrite(string mode)
+ {
+ // These two decide a row's colour and nothing else. A listing is not worth failing over a string of
+ // the wrong length, and an index off the end of one is how that would happen.
+ PosixMode.HasAnyExecuteBit(mode).ShouldBeFalse();
+ PosixMode.IsWorldWritable(mode).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(SftpEntryKind.File, "-rwxr-xr-x", true)]
+ [InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
+ [InlineData(SftpEntryKind.Directory, "drwxr-xr-x", false)]
+ [InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
+ public void AnEntry_IsExecutableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
+ {
+ // The x on a directory means "may be searched", which is true of very nearly every directory a host
+ // has. A file browser that marked them all would be marking nothing.
+ Entry(kind, mode).IsExecutable.ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData(SftpEntryKind.File, "-rw-rw-rw-", true)]
+ [InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
+ [InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
+ [InlineData(SftpEntryKind.Directory, "drwxrwxrwx", false)]
+ public void AnEntry_IsWorldWritableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
+ {
+ // The two exclusions are the point. Every symbolic link is lrwxrwxrwx and its mode governs nothing —
+ // what may be written is the target, whose mode this listing never fetched. And a world-writable
+ // directory is /tmp, made safe by a sticky bit PosixMode does not render: warning there would be
+ // warning about the half of the mode that is on screen.
+ Entry(kind, mode).IsWorldWritable.ShouldBe(expected);
+ }
+
[Theory]
[InlineData(0, "0 B")]
[InlineData(1023, "1023 B")]
@@ -96,4 +159,8 @@ public sealed class RemotePathTests
// "512.3 MB" claims a precision the figure does not have by the time it is that large.
ByteSize.Format(bytes).ShouldBe(expected);
}
+
+ /// An entry that is nothing but a kind and a mode, which is all these two questions read.
+ private static SftpEntry Entry(SftpEntryKind kind, string mode) =>
+ new("thing", "/tmp/thing", kind, 0, DateTimeOffset.UnixEpoch, mode);
}