This repository was archived by the owner on Aug 5, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUnixListingParser.cs
More file actions
180 lines (163 loc) · 6.88 KB
/
Copy pathUnixListingParser.cs
File metadata and controls
180 lines (163 loc) · 6.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace FtpClient.Protocol.Listing;
/// <summary>
/// Parses the Unix <c>ls -l</c> style listing that most FTP servers return for <c>LIST</c>.
/// </summary>
/// <remarks>
/// <para>
/// The layout is nine whitespace-separated fields followed by the name:
/// </para>
/// <code>
/// -rw-r--r-- 1 owner group 1024 Apr 4 12:06 my report.txt
/// ^perms ^links ^owner ^group ^size ^month ^day ^time ^name (may contain spaces)
/// </code>
/// <para>
/// The name is <b>everything after the timestamp field</b>, and this parser gets there by
/// consuming the eight fields in front of it. It never searches the line for a substring,
/// and it never falls back to a fixed column: both of those produce a wrong name instead of
/// a failure, and a wrong name is the one error a file browser cannot recover from.
/// </para>
/// <para>
/// The month is matched as "three letters in the month position", not as a known English
/// month. A server running under a non-English locale writes <c>kwi</c> or <c>мая</c>
/// there; that costs us the <see cref="FtpListEntry.Modified"/> value, and nothing else.
/// Date recognition and name extraction are deliberately independent.
/// </para>
/// </remarks>
public static partial class UnixListingParser
{
// A single space before the name, not \s+: `ls -l` separates the name from the
// timestamp with exactly one space, so a name that itself begins with a space survives.
[GeneratedRegex(
"""
^(?<perms>[bcdlps-][rwxsStTlL-]{9})[+@.]?\s+
(?<links>\d+)\s+
(?<owner>\S+)\s+
(?:(?<group>\S+)\s+)?
(?<size>\d+)\s+
(?<month>[A-Za-z]{3})\s+
(?<day>\d{1,2})\s+
(?<stamp>\d{4}|\d{1,2}:\d{2})
[ ](?<name>.+)$
""",
RegexOptions.IgnorePatternWhitespace | RegexOptions.CultureInvariant)]
private static partial Regex LineRegex();
private static readonly string[] Months =
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
private const string LinkSeparator = " -> ";
/// <summary>Does this line look like a Unix long-format listing entry?</summary>
public static bool IsMatch(string line) => LineRegex().IsMatch(line);
/// <summary>
/// Parses one listing line. Returns <c>false</c> for anything that is not an entry —
/// <c>total 12</c>, a banner, a truncated line — without throwing.
/// </summary>
/// <param name="line">The line, without its terminator.</param>
/// <param name="now">
/// Reference time used to pick the year for entries that show a clock time instead of a
/// year. Injected so the behaviour is testable at a fixed instant.
/// </param>
/// <param name="entry">The parsed entry, when this returns <c>true</c>.</param>
public static bool TryParse(string line, DateTimeOffset now, [NotNullWhen(true)] out FtpListEntry? entry)
{
entry = null;
Match match = LineRegex().Match(line);
if (!match.Success)
{
return false;
}
string permissions = match.Groups["perms"].Value;
string name = match.Groups["name"].Value;
string? linkTarget = null;
FtpEntryType type = permissions[0] switch
{
'd' => FtpEntryType.Directory,
'l' => FtpEntryType.SymbolicLink,
'-' => FtpEntryType.File,
_ => FtpEntryType.Other,
};
// A symlink prints as `name -> target`. Split on the first separator only: the
// target may itself contain " -> ", the name may not be split more than once.
if (type == FtpEntryType.SymbolicLink)
{
int arrow = name.IndexOf(LinkSeparator, StringComparison.Ordinal);
if (arrow > 0)
{
linkTarget = name[(arrow + LinkSeparator.Length)..];
name = name[..arrow];
}
}
entry = new FtpListEntry
{
Name = name,
Type = type,
RawLine = line,
Permissions = permissions[1..],
Owner = match.Groups["owner"].Value,
Group = match.Groups["group"].Success ? match.Groups["group"].Value : null,
LinkTarget = linkTarget,
Size = long.TryParse(match.Groups["size"].Value, out long size) ? size : null,
Modified = TryReadTimestamp(
match.Groups["month"].Value,
match.Groups["day"].Value,
match.Groups["stamp"].Value,
now),
};
return true;
}
/// <summary>
/// Turns the three date fields into an instant, or <c>null</c> when the month is not one
/// we recognise.
/// </summary>
/// <remarks>
/// <c>ls</c> prints a clock time for entries inside a recent window and a year for
/// everything older. When we get a clock time we assume the most recent such date that
/// is not in the future, which is the same rule <c>ls</c> itself uses in reverse.
/// </remarks>
private static DateTimeOffset? TryReadTimestamp(string month, string day, string stamp, DateTimeOffset now)
{
int monthNumber = Array.IndexOf(Months, month.ToLowerInvariant()) + 1;
if (monthNumber == 0 || !int.TryParse(day, out int dayNumber))
{
return null;
}
if (stamp.Contains(':', StringComparison.Ordinal))
{
string[] parts = stamp.Split(':');
if (!int.TryParse(parts[0], out int hour) || !int.TryParse(parts[1], out int minute))
{
return null;
}
if (!TryBuild(now.Year, monthNumber, dayNumber, hour, minute, now.Offset, out DateTimeOffset candidate))
{
return null;
}
// A date more than a day ahead of "now" belongs to last year: the server showed
// a clock time, so it is inside the recent window, not twelve months away.
return candidate > now.AddDays(1)
? TryBuild(now.Year - 1, monthNumber, dayNumber, hour, minute, now.Offset, out DateTimeOffset previous)
? previous
: null
: candidate;
}
return int.TryParse(stamp, out int year)
&& TryBuild(year, monthNumber, dayNumber, 0, 0, now.Offset, out DateTimeOffset dated)
? dated
: null;
}
private static bool TryBuild(
int year, int month, int day, int hour, int minute, TimeSpan offset, out DateTimeOffset value)
{
// 29 February in a non-leap year is the realistic way this throws.
try
{
value = new DateTimeOffset(year, month, day, hour, minute, 0, offset);
return true;
}
catch (ArgumentOutOfRangeException)
{
value = default;
return false;
}
}
}