using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Http;
using Sunfish.Log;
using Sunfish.Shares;
using Sunfish.Web;
namespace Sunfish.Ftp;
/// <summary>
/// Una conexion de control FTP. Sirve el mismo arbol de shares que la web: el usuario anonimo
/// ve los shares publicos y un usuario con contrasena, ademas, los que lo incluyen en Users.
/// Los permisos son los del share. Mientras dura una transferencia se sigue leyendo el control
/// para atender ABOR.
/// </summary>
internal sealed class FtpSession
{
private const int MaxLine = 4096;
private readonly Socket _socket;
private readonly Func<ServerState> _state;
private readonly ServerLog _log;
private readonly Authenticator _auth;
private readonly CancellationToken _shutdown;
private readonly IPEndPoint _remote;
private readonly IPEndPoint _local;
private readonly DataChannel _data;
private Stream _control = Stream.Null;
private readonly byte[] _buffer = new byte[4096];
private int _pos, _len;
private Task<string?>? _pendingLine;
private string? _pendingUser;
private string? _user; // null = sin identificar; "" = anonimo
private ClientKind? _profile;
private bool _utf8;
private Encoding _legacy = Encoding.Latin1;
private List<string> _cwd = new();
private bool _ascii;
private long _restart;
private List<string>? _renameFrom;
public FtpSession(Socket socket, Func<ServerState> state, ServerLog log, Authenticator auth, CancellationToken shutdown)
{
_socket = socket;
_state = state;
_log = log;
_auth = auth;
_shutdown = shutdown;
_remote = (IPEndPoint)socket.RemoteEndPoint!;
_local = (IPEndPoint)socket.LocalEndPoint!;
_data = new DataChannel(_remote.Address);
}
private IPAddress RemoteAddress => DataChannel.Normalize(_remote.Address);
public async Task RunAsync()
{
_socket.NoDelay = true;
_control = new TimeoutStream(new NetworkStream(_socket, ownsSocket: true), _shutdown)
{
ReadLimit = TimeSpan.FromMinutes(5),
WriteLimit = TimeSpan.FromSeconds(60),
};
_legacy = LegacyEncoding(_state().Config.Server.FtpCharset);
try
{
await ReplyAsync(220, $"{_state().Config.Server.Name} - Sunfish 3.0 FTP");
while (!_shutdown.IsCancellationRequested)
{
string? line = await (_pendingLine ?? ReadLineAsync());
_pendingLine = null;
if (line is null)
break;
if (!await HandleAsync(line))
break;
}
}
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
_log.Detail(Strings.ConnectionError(RemoteAddress.ToString(), e.Message));
}
catch (Exception e)
{
_log.Error(Strings.ConnectionError(RemoteAddress.ToString(), e.Message));
}
finally
{
_data.Dispose();
await _control.DisposeAsync();
}
}
// ------------------------------------------------------------------ control
/// <summary>
/// Una linea de control, sin CR/LF y sin secuencias Telnet (IAC). Se decodifica como UTF-8 si
/// es valido y, si no, con el juego de caracteres de la sesion.
/// </summary>
private async Task<string?> ReadLineAsync()
{
var bytes = new List<byte>();
while (true)
{
if (_pos >= _len)
{
_len = await _control.ReadAsync(_buffer, _shutdown);
_pos = 0;
if (_len == 0)
return bytes.Count > 0 ? Decode(bytes.ToArray()) : null;
}
byte b = _buffer[_pos++];
if (b == 0xFF)
{
// IAC: los clientes antiguos envian IAC IP / IAC DM antes de ABOR.
if (_pos >= _len)
{
_len = await _control.ReadAsync(_buffer, _shutdown);
_pos = 0;
if (_len == 0)
return null;
}
byte cmd = _buffer[_pos++];
if (cmd == 0xFF)
bytes.Add(0xFF);
else if (cmd is >= 251 and <= 254 && _pos < _len)
_pos++; // WILL/WONT/DO/DONT llevan una opcion
continue;
}
if (b == '\n')
break;
bytes.Add(b);
if (bytes.Count > MaxLine)
throw new IOException("Command line too long");
}
if (bytes.Count > 0 && bytes[^1] == '\r')
bytes.RemoveAt(bytes.Count - 1);
return Decode(bytes.ToArray());
}
private string Decode(byte[] bytes)
{
try
{
return new UTF8Encoding(false, true).GetString(bytes);
}
catch (DecoderFallbackException)
{
return _legacy.GetString(bytes);
}
}
private Encoding NamesEncoding => _utf8 ? new UTF8Encoding(false) : _legacy;
private Task ReplyAsync(int code, string text) => SendAsync($"{code} {text}\r\n");
private Task ReplyMultiAsync(int code, IEnumerable<string> lines, string end)
{
var sb = new StringBuilder();
sb.Append(code).Append('-').Append(lines.FirstOrDefault() ?? "").Append("\r\n");
foreach (var line in lines.Skip(1))
sb.Append(' ').Append(line).Append("\r\n");
sb.Append(code).Append(' ').Append(end).Append("\r\n");
return SendAsync(sb.ToString());
}
private async Task SendAsync(string text) =>
await _control.WriteAsync(NamesEncoding.GetBytes(text), _shutdown);
/// <summary>Devuelve false para cerrar la sesion.</summary>
private async Task<bool> HandleAsync(string line)
{
int space = line.IndexOf(' ');
string command = (space < 0 ? line : line[..space]).Trim().ToUpperInvariant();
string argument = space < 0 ? "" : line[(space + 1)..];
_log.Detail($"{RemoteAddress} FTP {(command == "PASS" ? "PASS ***" : line)}");
switch (command)
{
case "QUIT":
await ReplyAsync(221, "Bye");
return false;
case "USER": await UserAsync(argument.Trim()); return true;
case "PASS": await PassAsync(argument); return true;
case "NOOP": await ReplyAsync(200, "OK"); return true;
case "SYST": await ReplyAsync(215, "UNIX Type: L8"); return true;
case "FEAT": await FeatAsync(); return true;
case "OPTS": await OptsAsync(argument); return true;
case "HELP": await HelpAsync(); return true;
case "CLNT": await ReplyAsync(200, "OK"); return true;
case "AUTH" or "PBSZ" or "PROT" or "CCC":
await ReplyAsync(502, "TLS not available");
return true;
}
if (_user is null)
{
await ReplyAsync(530, "Please login with USER and PASS");
return true;
}
switch (command)
{
case "PWD" or "XPWD": await ReplyAsync(257, FtpPath.Quote(FtpPath.Format(_cwd)) + " is the current directory"); break;
case "CWD" or "XCWD": await CwdAsync(argument); break;
case "CDUP" or "XCUP": await CwdAsync(".."); break;
case "TYPE": await TypeAsync(argument); break;
case "MODE": await ReplyAsync(argument.Trim().Equals("S", StringComparison.OrdinalIgnoreCase) ? 200 : 504, "Mode"); break;
case "STRU": await ReplyAsync(argument.Trim().Equals("F", StringComparison.OrdinalIgnoreCase) ? 200 : 504, "Structure"); break;
case "ALLO": await ReplyAsync(202, "No storage allocation necessary"); break;
case "PASV": await PasvAsync(extended: false); break;
case "EPSV": await PasvAsync(extended: true); break;
case "PORT": await PortAsync(DataChannel.ParsePort(argument)); break;
case "EPRT": await PortAsync(DataChannel.ParseEprt(argument.Trim())); break;
case "REST": await RestAsync(argument); break;
case "LIST": await ListAsync(argument, ListKind.Unix); break;
case "NLST": await ListAsync(argument, ListKind.Names); break;
case "MLSD": await ListAsync(argument, ListKind.Mlsd); break;
case "MLST": await MlstAsync(argument); break;
case "RETR": await RetrAsync(argument); break;
case "STOR": await StorAsync(argument, append: false); break;
case "APPE": await StorAsync(argument, append: true); break;
case "SIZE": await SizeAsync(argument); break;
case "MDTM": await MdtmAsync(argument); break;
case "MKD" or "XMKD": await MkdAsync(argument); break;
case "RMD" or "XRMD": await RmdAsync(argument); break;
case "DELE": await DeleAsync(argument); break;
case "RNFR": await RnfrAsync(argument); break;
case "RNTO": await RntoAsync(argument); break;
case "SITE": await SiteAsync(argument); break;
case "STAT": await StatAsync(); break;
case "ABOR": await ReplyAsync(226, "No transfer to abort"); break;
default: await ReplyAsync(502, "Command not implemented"); break;
}
return true;
}
// ------------------------------------------------------------------ sesion
private async Task UserAsync(string name)
{
// "usuario+dos", "anonymous+mac"...: perfil de cliente para esta sesion (DOC/CLIENTES.md).
_profile = null;
int plus = name.LastIndexOf('+');
if (plus > 0 && ClientProfile.TryParseCode(name[(plus + 1)..], out var kind))
{
_profile = kind;
name = name[..plus];
}
_pendingUser = name;
_user = null;
ApplyProfileCharset();
if (IsAnonymous(name))
await ReplyAsync(331, "Anonymous login: send any password");
else
await ReplyAsync(331, "Password required");
}
private static bool IsAnonymous(string name) =>
name.Equals("anonymous", StringComparison.OrdinalIgnoreCase) || name.Equals("ftp", StringComparison.OrdinalIgnoreCase);
private async Task PassAsync(string password)
{
if (_pendingUser is null)
{
await ReplyAsync(503, "Send USER first");
return;
}
string name = _pendingUser;
_pendingUser = null;
if (IsAnonymous(name))
{
_user = "";
_log.Info($"{RemoteAddress} FTP login anonymous");
await ReplyAsync(230, "Logged in (anonymous)");
return;
}
var config = _state().Config;
var result = _auth.Check(config.Users, config.Users.Select(u => u.Name).ToList(), name, password, RemoteAddress);
switch (result)
{
case AuthResult.Ok:
_user = config.Users.First(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).Name;
_log.Info($"{RemoteAddress} FTP login {_user}");
await ReplyAsync(230, "Logged in");
break;
case AuthResult.Blocked:
await ReplyAsync(421, "Too many failed attempts, try again later");
throw new IOException("Blocked");
default:
_log.Warning($"{RemoteAddress}: FTP auth failed for '{name}'");
await Task.Delay(1000, _shutdown);
await ReplyAsync(530, "Login incorrect");
break;
}
}
private void ApplyProfileCharset()
{
_legacy = _profile switch
{
ClientKind.Dos => LegacyEncoding("cp437"),
ClientKind.Win16 => LegacyEncoding("windows-1252"),
ClientKind.Mac => LegacyEncoding("macroman"),
_ => LegacyEncoding(_state().Config.Server.FtpCharset),
};
}
internal static Encoding LegacyEncoding(string name)
{
Encodings.EnsureRegistered();
int codePage = name.Trim().ToLowerInvariant() switch
{
"macroman" or "macintosh" => 10000,
"cp437" or "ibm437" => 437,
"cp850" or "ibm850" => 850,
"windows-1252" or "cp1252" => 1252,
"utf-8" or "utf8" => 65001,
_ => 28591, // iso-8859-1
};
return Encoding.GetEncoding(codePage, new EncoderReplacementFallback("?"), new DecoderReplacementFallback("?"));
}
private Task FeatAsync() => ReplyMultiAsync(211,
[
"Features:", "UTF8", "MLST type*;size*;modify*;perm*;", "SIZE", "MDTM", "REST STREAM", "EPSV", "EPRT", "PASV",
], "End");
private async Task OptsAsync(string argument)
{
var parts = argument.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 1 && parts[0].Equals("UTF8", StringComparison.OrdinalIgnoreCase))
{
_utf8 = parts.Length < 2 || parts[1].Equals("ON", StringComparison.OrdinalIgnoreCase);
await ReplyAsync(200, _utf8 ? "UTF8 mode on" : "UTF8 mode off");
return;
}
if (parts.Length >= 1 && parts[0].Equals("MLST", StringComparison.OrdinalIgnoreCase))
{
await ReplyAsync(200, "MLST OPTS type;size;modify;perm;");
return;
}
await ReplyAsync(501, "Option not understood");
}
private Task HelpAsync() => ReplyMultiAsync(214,
[
"Commands:",
"USER PASS QUIT NOOP SYST FEAT OPTS HELP STAT SITE",
"PWD CWD CDUP TYPE MODE STRU PASV EPSV PORT EPRT REST",
"LIST NLST MLSD MLST RETR STOR APPE SIZE MDTM",
"MKD RMD DELE RNFR RNTO ABOR",
"SITE PROFILE dos|win16|mac|none (or USER name+dos)",
], "OK");
private async Task SiteAsync(string argument)
{
var parts = argument.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && parts[0].Equals("PROFILE", StringComparison.OrdinalIgnoreCase))
{
if (parts[1].Equals("none", StringComparison.OrdinalIgnoreCase))
_profile = null;
else if (ClientProfile.TryParseCode(parts[1], out var kind))
_profile = kind;
else
{
await ReplyAsync(501, "Unknown profile");
return;
}
ApplyProfileCharset();
await ReplyAsync(200, "Profile " + (_profile?.ToString().ToLowerInvariant() ?? "none"));
return;
}
await ReplyAsync(214, "SITE PROFILE dos|win16|mac|none");
}
private async Task StatAsync()
{
string who = string.IsNullOrEmpty(_user) ? "anonymous" : _user;
await ReplyMultiAsync(211,
[
"Sunfish 3.0 FTP status:",
$"Connected from {RemoteAddress}",
$"Logged in as {who}",
$"TYPE: {(_ascii ? "ASCII" : "BINARY")}, names: {(_utf8 ? "UTF-8" : _legacy.WebName)}",
], "End of status");
}
private async Task TypeAsync(string argument)
{
string t = argument.Trim().ToUpperInvariant();
if (t.StartsWith('A'))
_ascii = true;
else if (t.StartsWith('I') || t.StartsWith("L"))
_ascii = false;
else
{
await ReplyAsync(504, "Type not supported");
return;
}
await ReplyAsync(200, "Type set to " + (_ascii ? "A" : "I"));
}
private async Task RestAsync(string argument)
{
if (!long.TryParse(argument.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long offset))
{
await ReplyAsync(501, "Invalid offset");
return;
}
_restart = offset;
await ReplyAsync(350, $"Restarting at {offset}");
}
// ------------------------------------------------------------------ arbol de shares
/// <summary>Un nodo del arbol visto por esta sesion.</summary>
/// <param name="MacBin">"nombre.bin" que un Mac clasico ve por un fichero con AppleDouble: se sirve en MacBinary.</param>
private sealed record Node(List<string> Segments, ShareConfig? Share, IReadOnlyList<string> Relative,
string? Physical, IReadOnlyList<string> VirtualChildren, bool MacBin = false)
{
public bool IsFile => Physical is not null && File.Exists(Physical);
public bool IsRealDirectory => Physical is not null && Directory.Exists(Physical);
public bool IsDirectory => IsRealDirectory || VirtualChildren.Count > 0 || Segments.Count == 0;
public bool Exists => IsFile || IsDirectory;
public bool Writable => Share is not null && IsRealDirectory;
}
/// <summary>Shares que ve esta sesion: activos, con FTP y publicos o con este usuario.</summary>
private ShareTree Tree()
{
var shares = _state().Config.Shares.Where(s =>
s.Enabled && s.Ftp &&
(s.Users.Count == 0 || (!string.IsNullOrEmpty(_user) && s.Users.Contains(_user, StringComparer.OrdinalIgnoreCase))));
return new ShareTree(shares);
}
private Node? Resolve(string? argument)
{
var segments = FtpPath.Resolve(_cwd, argument);
if (segments is null)
return null;
var resolution = Tree().Resolve(segments);
var share = resolution.Share;
string? physical = share is null ? null : ShareFileSystem.TryGetPhysicalPath(share, resolution.Relative, aliases: share.ShortNames);
// Un Mac clasico ve "nombre.bin" por cada fichero con resource fork.
if (share is { Mac: true } && _profile == ClientKind.Mac && physical is not null &&
!File.Exists(physical) && !Directory.Exists(physical) &&
physical.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) &&
physical[..^4] is var data && File.Exists(data) && Mac.MacFiles.HasCompanion(data))
return new Node(segments, share, resolution.Relative, data, resolution.VirtualChildren, MacBin: true);
return new Node(segments, share, resolution.Relative, physical, resolution.VirtualChildren);
}
/// <summary>Como ven este cliente los nombres y tamanos: alias 8.3 (DOS, Windows 3.1) o .bin (Mac clasico).</summary>
private List<FileEntry> Present(Node dir, List<FileEntry> entries)
{
if (dir.Share is not { } share || !dir.IsRealDirectory)
return entries;
if (share.ShortNames && _profile is ClientKind.Dos or ClientKind.Win16)
{
var map = ShortNames.Map(new DirectoryInfo(dir.Physical!).EnumerateFileSystemInfos().Select(i => i.Name));
return entries.Select(e => e.IsVirtual ? e : e with { Name = map.GetValueOrDefault(e.Name, e.Name) }).ToList();
}
if (share.Mac && _profile == ClientKind.Mac)
return entries.Select(e =>
{
string path = Path.Combine(dir.Physical!, e.Name);
return e.IsDirectory || e.IsVirtual || !Mac.MacFiles.HasCompanion(path)
? e
: e with { Name = e.Name + ".bin", Size = MacBinLength(path) };
}).ToList();
return entries;
}
private static long MacBinLength(string path) =>
Mac.MacBinary.EncodedLength(new FileInfo(path).Length, Mac.MacFiles.Describe(path).ResourceFork.Length);
/// <summary>Carpeta padre real y nombre del elemento para crear, renombrar o borrar.</summary>
private (Node Parent, string Name)? ParentOf(Node node)
{
if (node.Segments.Count == 0)
return null;
var parent = Resolve(FtpPath.Format(node.Segments.Take(node.Segments.Count - 1).ToList()));
if (parent is null || parent.Share is null || !parent.IsRealDirectory || parent.Share != node.Share)
return null;
string name = node.Segments[^1];
if (parent.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase))
return null; // una location mas especifica tapa este nombre
// Si se nombro por su alias 8.3 o como .bin, el nombre real es el del disco.
if (node.Physical is not null && (File.Exists(node.Physical) || Directory.Exists(node.Physical)))
name = Path.GetFileName(node.Physical);
return (parent, name);
}
private List<FileEntry> Children(Node dir)
{
var items = dir.VirtualChildren.Select(c => new FileEntry(c, true, 0, default, IsVirtual: true)).ToList();
if (dir.IsRealDirectory)
items.AddRange(ShareFileSystem.List(dir.Physical!)
.Where(e => !dir.VirtualChildren.Contains(e.Name, StringComparer.OrdinalIgnoreCase)));
return items.OrderBy(e => e.IsDirectory ? 0 : 1).ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
private static FileEntry EntryOf(Node node)
{
string name = node.Segments.Count == 0 ? "/" : node.Segments[^1];
if (node.IsFile)
{
var info = new FileInfo(node.Physical!);
return new FileEntry(name, false, node.MacBin ? MacBinLength(node.Physical!) : info.Length, info.LastWriteTime);
}
if (node.IsRealDirectory)
return new FileEntry(name, true, 0, Directory.GetLastWriteTime(node.Physical!));
return new FileEntry(name, true, 0, default, IsVirtual: true);
}
private async Task CwdAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.IsDirectory)
{
await ReplyAsync(550, "No such directory");
return;
}
_cwd = node.Segments;
await ReplyAsync(250, "Directory changed to " + FtpPath.Format(_cwd));
}
// ------------------------------------------------------------------ conexion de datos
private async Task PasvAsync(bool extended)
{
var server = _state().Config.Server;
IPAddress local = DataChannel.Normalize(_local.Address);
if (!extended && local.AddressFamily != AddressFamily.InterNetwork)
{
await ReplyAsync(425, "Use EPSV with IPv6");
return;
}
int port;
try
{
port = _data.OpenPassive(local, server.FtpPassivePorts);
}
catch (SocketException)
{
await ReplyAsync(425, "No passive port available");
return;
}
if (extended)
{
await ReplyAsync(229, $"Entering Extended Passive Mode (|||{port}|)");
return;
}
var announced = server.FtpPublicAddress ?? local;
var b = announced.GetAddressBytes();
await ReplyAsync(227, $"Entering Passive Mode ({b[0]},{b[1]},{b[2]},{b[3]},{port / 256},{port % 256})");
}
private async Task PortAsync(IPEndPoint? endpoint)
{
if (endpoint is null || !_data.SetActive(endpoint))
{
await ReplyAsync(500, "Illegal PORT command (only to your own address and ports >= 1024)");
return;
}
await ReplyAsync(200, "PORT command successful");
}
/// <summary>
/// Abre la conexion de datos, ejecuta la transferencia y responde 226, o 426 si se aborta
/// (con ABOR, que se atiende mientras tanto) o falla. Devuelve si termino bien.
/// </summary>
private async Task<bool> TransferAsync(string what, Func<Stream, CancellationToken, Task> work)
{
if (!_data.Ready)
{
await ReplyAsync(425, "Use PORT or PASV first");
return false;
}
await ReplyAsync(150, "Opening data connection for " + what);
using var abort = CancellationTokenSource.CreateLinkedTokenSource(_shutdown);
var transfer = Task.Run(async () =>
{
await using var stream = await _data.ConnectAsync(abort.Token);
await work(stream, abort.Token);
});
_pendingLine ??= ReadLineAsync();
while (true)
{
var finished = await Task.WhenAny(transfer, _pendingLine);
if (finished == transfer)
break;
string? line = await _pendingLine;
if (line is not null && line.Trim().TrimStart('�').ToUpperInvariant().StartsWith("ABOR", StringComparison.Ordinal))
{
_pendingLine = null;
abort.Cancel();
try { await transfer; } catch { /* abortada */ }
await ReplyAsync(426, "Transfer aborted");
await ReplyAsync(226, "ABOR command successful");
return false;
}
// Otro comando a mitad de transferencia: se atiende cuando termine.
await transfer.ContinueWith(_ => { }, TaskScheduler.Default);
break;
}
try
{
await transfer;
await ReplyAsync(226, "Transfer complete");
return true;
}
catch (Exception e) when (e is IOException or SocketException or OperationCanceledException or InvalidOperationException or UnauthorizedAccessException)
{
_log.Detail($"{RemoteAddress} FTP transfer failed: {e.Message}");
await ReplyAsync(426, "Connection closed; transfer aborted");
return false;
}
}
// ------------------------------------------------------------------ listados
private enum ListKind { Unix, Names, Mlsd }
private async Task ListAsync(string argument, ListKind kind)
{
// "LIST -la carpeta": las opciones de ls se ignoran.
string path = string.Join(' ', argument.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.SkipWhile(p => kind != ListKind.Mlsd && p.StartsWith('-')));
var node = Resolve(path);
if (node is null || !node.Exists)
{
await ReplyAsync(550, "No such file or directory");
return;
}
if (node.IsDirectory && node.Share is not null && node.IsRealDirectory && !node.Share.Listing)
{
await ReplyAsync(550, "Listing not allowed here");
return;
}
if (kind == ListKind.Mlsd && !node.IsDirectory)
{
await ReplyAsync(501, "Not a directory");
return;
}
var entries = node.IsDirectory ? Present(node, Children(node)) : [EntryOf(node)];
var now = DateTime.Now;
var sb = new StringBuilder();
foreach (var e in entries)
{
switch (kind)
{
case ListKind.Unix:
sb.Append(FtpListing.UnixLine(e, now));
break;
case ListKind.Names:
sb.Append(e.Name);
break;
default:
sb.Append(FtpListing.Facts(e, e.IsDirectory ? "dir" : "file", e.IsVirtual ? null : node.Share, !node.Writable));
break;
}
sb.Append("\r\n");
}
// MLSD es siempre UTF-8 (RFC 3659); LIST y NLST, en el juego de la sesion.
byte[] bytes = (kind == ListKind.Mlsd ? new UTF8Encoding(false) : NamesEncoding).GetBytes(sb.ToString());
await TransferAsync("file list", (stream, ct) => stream.WriteAsync(bytes, ct).AsTask());
}
private async Task MlstAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.Exists)
{
await ReplyAsync(550, "No such file or directory");
return;
}
var entry = EntryOf(node) with { Name = FtpPath.Format(node.Segments) };
string facts = FtpListing.Facts(entry, entry.IsDirectory ? "dir" : "file", node.Share, !(node.IsFile ? node.Share is not null : node.Writable));
await ReplyMultiAsync(250, ["Listing " + FtpPath.Format(node.Segments), facts], "End");
}
// ------------------------------------------------------------------ ficheros
private async Task RetrAsync(string argument)
{
long offset = _restart;
_restart = 0;
var node = Resolve(argument);
if (node is null || !node.IsFile)
{
await ReplyAsync(550, "No such file");
return;
}
string path = node.Physical!;
bool ascii = _ascii;
bool macBin = node.MacBin;
bool ok = await TransferAsync(FtpPath.Format(node.Segments), async (stream, ct) =>
{
await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true);
if (macBin)
{
// Siempre binario; REST se respeta descartando el principio de la codificacion.
await using var output = offset > 0 ? new SkipStream(stream, offset) : null;
await Mac.MacBinary.EncodeAsync(Mac.MacFiles.Describe(path), file, file.Length, (Stream?)output ?? stream, ct);
return;
}
if (offset > 0)
file.Position = Math.Min(offset, file.Length);
if (ascii)
await CopyAsciiAsync(file, stream, ct);
else
await file.CopyToAsync(stream, 65536, ct);
});
if (ok)
_log.Info($"{RemoteAddress} FTP RETR {FtpPath.Format(node.Segments)}");
}
/// <summary>TYPE A: los saltos de linea solos (LF) se envian como CRLF.</summary>
private static async Task CopyAsciiAsync(Stream input, Stream output, CancellationToken ct)
{
var buffer = new byte[32768];
var outBuffer = new byte[65536];
byte previous = 0;
int n;
while ((n = await input.ReadAsync(buffer, ct)) > 0)
{
int o = 0;
for (int i = 0; i < n; i++)
{
byte b = buffer[i];
if (b == '\n' && previous != '\r')
outBuffer[o++] = (byte)'\r';
outBuffer[o++] = b;
previous = b;
}
await output.WriteAsync(outBuffer.AsMemory(0, o), ct);
}
}
private async Task StorAsync(string argument, bool append)
{
long offset = _restart;
_restart = 0;
var node = Resolve(argument);
// Subir "x.bin" no escribe sobre "x": se guarda tal cual y luego se decodifica.
if (node is { MacBin: true })
node = node with { Physical = node.Physical + ".bin", MacBin = false };
var target = node is null ? null : ParentOf(node);
if (node is null || target is not { } t || node.IsDirectory)
{
await ReplyAsync(553, "Cannot create a file here");
return;
}
var share = node.Share!;
bool exists = node.IsFile;
if (!share.Upload || (exists && !share.Overwrite) || ((append || offset > 0) && !share.Overwrite && exists))
{
await ReplyAsync(550, exists ? "File exists and overwriting is not allowed" : "Uploads are not allowed here");
return;
}
if (!FileOperations.IsCreatableName(t.Name))
{
await ReplyAsync(553, "Invalid file name");
return;
}
string directory = t.Parent.Physical!;
string path = Path.Combine(directory, t.Name);
bool inPlace = append || offset > 0;
FileOpResult result = FileOpResult.Ok;
bool ok = await TransferAsync(FtpPath.Format(node.Segments), async (stream, ct) =>
{
if (inPlace)
{
// APPE y REST+STOR escriben sobre el fichero (reanudar una subida).
await using var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 65536, useAsync: true);
file.Position = append ? file.Length : Math.Min(offset, file.Length);
await stream.CopyToAsync(file, 65536, ct);
file.SetLength(file.Position);
}
else
{
result = await FileOperations.SaveAsync(directory, t.Name, stream, share.Overwrite, ct);
// Se decodifica antes del 226, para que el cliente ya vea el resultado.
if (result == FileOpResult.Ok && share.Mac)
await UnpackMacAsync(share, path);
}
});
if (ok && result == FileOpResult.Ok)
{
_log.Info($"{RemoteAddress} FTP {(append ? "APPE" : "STOR")} {FtpPath.Format(node.Segments)}");
}
else if (ok)
_log.Warning($"{RemoteAddress} FTP STOR {FtpPath.Format(node.Segments)}: {result}");
}
/// <summary>Tras un STOR completo: un MacBinary o BinHex se separa en data fork + AppleDouble.</summary>
private async Task UnpackMacAsync(ShareConfig share, string path)
{
try
{
var (result, name) = await Mac.MacFiles.TryUnpackAsync(path, share.Overwrite, _profile == ClientKind.Mac, _shutdown);
if (result == Mac.MacFiles.Unpacked.Unpacked)
_log.Info($"{RemoteAddress} FTP mac {Path.GetFileName(path)} -> {name}");
else if (result == Mac.MacFiles.Unpacked.ExistsKept)
_log.Warning($"{RemoteAddress} FTP mac {Path.GetFileName(path)}: name inside already exists, kept encoded");
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or EndOfStreamException)
{
_log.Warning($"{RemoteAddress} FTP mac {Path.GetFileName(path)}: {e.Message}");
}
}
private async Task SizeAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.IsFile)
await ReplyAsync(550, "No such file");
else
await ReplyAsync(213, EntryOf(node).Size.ToString(CultureInfo.InvariantCulture));
}
/// <summary>Descarta los primeros bytes escritos (REST sobre una codificacion generada al vuelo).</summary>
private sealed class SkipStream(Stream inner, long skip) : Stream
{
private long _skip = skip;
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() => inner.Flush();
public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct);
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
if (_skip >= buffer.Length)
{
_skip -= buffer.Length;
return;
}
var rest = buffer[(int)_skip..];
_skip = 0;
await inner.WriteAsync(rest, ct);
}
// No cierra la conexion de datos: eso lo hace quien la abrio.
protected override void Dispose(bool disposing) { }
public override ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private async Task MdtmAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.IsFile)
await ReplyAsync(550, "No such file");
else
await ReplyAsync(213, FtpListing.Timestamp(File.GetLastWriteTime(node.Physical!)));
}
private async Task MkdAsync(string argument)
{
var node = Resolve(argument);
var target = node is null ? null : ParentOf(node);
if (node is null || target is not { } t || !node.Share!.Upload)
{
await ReplyAsync(550, "Cannot create a directory here");
return;
}
var result = FileOperations.CreateFolder(t.Parent.Physical!, t.Name);
if (result == FileOpResult.Ok)
{
_log.Info($"{RemoteAddress} FTP MKD {FtpPath.Format(node.Segments)}");
await ReplyAsync(257, FtpPath.Quote(FtpPath.Format(node.Segments)) + " created");
}
else
{
await ReplyAsync(550, Strings.OpResult(result, t.Name));
}
}
private async Task RmdAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.IsRealDirectory || ParentOf(node) is null || !node.Share!.Delete)
{
await ReplyAsync(550, "Cannot remove this directory");
return;
}
var result = FileOperations.DeleteEmptyFolder(node.Physical!);
if (result == FileOpResult.Ok)
{
_log.Info($"{RemoteAddress} FTP RMD {FtpPath.Format(node.Segments)}");
await ReplyAsync(250, "Directory removed");
}
else
{
await ReplyAsync(550, result == FileOpResult.Conflict ? "Directory not empty" : "Cannot remove this directory");
}
}
private async Task DeleAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.IsFile || ParentOf(node) is null || !node.Share!.Delete)
{
await ReplyAsync(550, "Cannot delete this file");
return;
}
var result = FileOperations.Delete(node.Physical!);
if (result == FileOpResult.Ok)
_log.Info($"{RemoteAddress} FTP DELE {FtpPath.Format(node.Segments)}");
await ReplyAsync(result == FileOpResult.Ok ? 250 : 550, result == FileOpResult.Ok ? "File deleted" : "Cannot delete this file");
}
private async Task RnfrAsync(string argument)
{
var node = Resolve(argument);
if (node is null || !node.Exists || node.Share is null || ParentOf(node) is null || !node.Share.Rename)
{
_renameFrom = null;
await ReplyAsync(550, "Cannot rename this");
return;
}
_renameFrom = node.Segments;
await ReplyAsync(350, "Ready for RNTO");
}
private async Task RntoAsync(string argument)
{
var fromSegments = _renameFrom;
_renameFrom = null;
if (fromSegments is null)
{
await ReplyAsync(503, "Send RNFR first");
return;
}
var from = Resolve(FtpPath.Format(fromSegments));
var to = Resolve(argument);
var target = to is null ? null : ParentOf(to);
if (from is null || !from.Exists || to is null || target is null || from.Share != to.Share || !from.Share!.Rename)
{
await ReplyAsync(553, "Cannot rename to that name");
return;
}
var result = FileOperations.Move(from.Physical!, Path.Combine(target.Value.Parent.Physical!, target.Value.Name));
if (result == FileOpResult.Ok)
{
_log.Info($"{RemoteAddress} FTP RNTO {FtpPath.Format(from.Segments)} -> {FtpPath.Format(to.Segments)}");
await ReplyAsync(250, "Rename successful");
}
else
{
await ReplyAsync(553, Strings.OpResult(result, target.Value.Name));
}
}
}