Phase 06 complete: - Namespace renamed in 62 .cs files (namespace + using directives) - AssemblyName + RootNamespace updated in csproj files - FanucProgramManager.csproj → NcProgramManager.csproj - FanucProgramManager.sln → NcProgramManager.sln - FanucProgramManager.Tests/ → NcProgramManager.Tests/ - FANUCMachine.cs deleted (dead code, zero references) Fanuc-prefixed class names kept (FanucMachine, FanucProgram, IFanucMachine) — manufacturer identifiers, not project names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
9.8 KiB
C#
255 lines
9.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
namespace NcProgramManager.Tests.Integration.Stubs
|
|
{
|
|
/// <summary>
|
|
/// Minimal in-process FTP server using passive mode.
|
|
/// Supports USER, PASS, SYST, PWD, CWD, TYPE, PASV, LIST, NLST, RETR, STOR, DELE, QUIT.
|
|
/// Also accepts OPTS, FEAT, NOOP, CLNT, MODE, STRU as no-ops.
|
|
/// All replies use CRLF (per RFC 959), independent of host platform.
|
|
/// </summary>
|
|
public sealed class FtpServerStub : IDisposable
|
|
{
|
|
private const string CRLF = "\r\n";
|
|
|
|
private readonly TcpListener _listener;
|
|
private readonly Thread _acceptThread;
|
|
private volatile bool _running;
|
|
|
|
public int Port { get; }
|
|
|
|
/// <summary>In-memory file store: full path → content.</summary>
|
|
public Dictionary<string, string> Files { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public FtpServerStub()
|
|
{
|
|
_listener = new TcpListener(IPAddress.Loopback, 0);
|
|
_listener.Start();
|
|
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
_running = true;
|
|
_acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "FtpStub-Accept" };
|
|
_acceptThread.Start();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_running = false;
|
|
try { _listener.Stop(); } catch { }
|
|
}
|
|
|
|
private void AcceptLoop()
|
|
{
|
|
while (_running)
|
|
{
|
|
TcpClient client;
|
|
try { client = _listener.AcceptTcpClient(); }
|
|
catch { break; }
|
|
var t = new Thread(() => HandleClient(client)) { IsBackground = true, Name = "FtpStub-Client" };
|
|
t.Start();
|
|
}
|
|
}
|
|
|
|
private static void SendLine(Stream stream, string line)
|
|
{
|
|
byte[] bytes = Encoding.ASCII.GetBytes(line + CRLF);
|
|
stream.Write(bytes, 0, bytes.Length);
|
|
stream.Flush();
|
|
}
|
|
|
|
private static string ReadLine(Stream stream)
|
|
{
|
|
var sb = new StringBuilder();
|
|
int prev = -1;
|
|
while (true)
|
|
{
|
|
int b = stream.ReadByte();
|
|
if (b < 0) return sb.Length == 0 ? null : sb.ToString();
|
|
if (b == '\n')
|
|
{
|
|
if (prev == '\r')
|
|
sb.Length = sb.Length - 1; // strip CR
|
|
return sb.ToString();
|
|
}
|
|
sb.Append((char)b);
|
|
prev = b;
|
|
}
|
|
}
|
|
|
|
private void HandleClient(TcpClient client)
|
|
{
|
|
using (client)
|
|
using (var stream = client.GetStream())
|
|
{
|
|
string cwd = "/";
|
|
TcpListener dataListener = null;
|
|
|
|
SendLine(stream, "220 FtpServerStub ready");
|
|
|
|
string line;
|
|
while ((line = ReadLine(stream)) != null)
|
|
{
|
|
line = line.Trim();
|
|
if (string.IsNullOrEmpty(line)) continue;
|
|
|
|
string cmd, arg;
|
|
int sp = line.IndexOf(' ');
|
|
if (sp >= 0)
|
|
{
|
|
cmd = line.Substring(0, sp).ToUpperInvariant();
|
|
arg = line.Substring(sp + 1).Trim();
|
|
}
|
|
else
|
|
{
|
|
cmd = line.ToUpperInvariant();
|
|
arg = "";
|
|
}
|
|
|
|
switch (cmd)
|
|
{
|
|
case "USER":
|
|
SendLine(stream, "331 Password required");
|
|
break;
|
|
|
|
case "PASS":
|
|
SendLine(stream, "230 Logged in");
|
|
break;
|
|
|
|
case "SYST":
|
|
SendLine(stream, "215 UNIX Type: L8");
|
|
break;
|
|
|
|
case "FEAT":
|
|
SendLine(stream, "211-Features:");
|
|
SendLine(stream, " UTF8");
|
|
SendLine(stream, "211 End");
|
|
break;
|
|
|
|
case "OPTS":
|
|
case "CLNT":
|
|
case "NOOP":
|
|
case "MODE":
|
|
case "STRU":
|
|
case "ALLO":
|
|
SendLine(stream, "200 OK");
|
|
break;
|
|
|
|
case "PWD":
|
|
SendLine(stream, "257 \"" + cwd + "\"");
|
|
break;
|
|
|
|
case "CWD":
|
|
cwd = arg.StartsWith("/") ? arg : cwd.TrimEnd('/') + "/" + arg;
|
|
if (!cwd.EndsWith("/")) cwd += "/";
|
|
SendLine(stream, "250 CWD successful");
|
|
break;
|
|
|
|
case "TYPE":
|
|
SendLine(stream, "200 Type set");
|
|
break;
|
|
|
|
case "PASV":
|
|
dataListener = new TcpListener(IPAddress.Loopback, 0);
|
|
dataListener.Start();
|
|
int dataPort = ((IPEndPoint)dataListener.LocalEndpoint).Port;
|
|
int p1 = dataPort >> 8;
|
|
int p2 = dataPort & 0xFF;
|
|
SendLine(stream, "227 Entering Passive Mode (127,0,0,1," + p1 + "," + p2 + ")");
|
|
break;
|
|
|
|
case "LIST":
|
|
case "NLST":
|
|
if (dataListener == null) { SendLine(stream, "425 No data connection"); break; }
|
|
SendLine(stream, "150 Opening data connection");
|
|
try
|
|
{
|
|
using (var dc = dataListener.AcceptTcpClient())
|
|
using (var ds = dc.GetStream())
|
|
{
|
|
// Empty listing — enough for Ping()
|
|
}
|
|
}
|
|
catch { }
|
|
try { dataListener.Stop(); } catch { }
|
|
dataListener = null;
|
|
SendLine(stream, "226 Transfer complete");
|
|
break;
|
|
|
|
case "RETR":
|
|
if (dataListener == null) { SendLine(stream, "425 No data connection"); break; }
|
|
string retrPath = NormalizePath(cwd, arg);
|
|
string retrContent;
|
|
if (!Files.TryGetValue(retrPath, out retrContent))
|
|
{
|
|
try { dataListener.Stop(); } catch { }
|
|
dataListener = null;
|
|
SendLine(stream, "550 File not found");
|
|
break;
|
|
}
|
|
SendLine(stream, "150 Opening data connection");
|
|
try
|
|
{
|
|
using (var dc = dataListener.AcceptTcpClient())
|
|
using (var ds = dc.GetStream())
|
|
{
|
|
byte[] bytes = Encoding.ASCII.GetBytes(retrContent);
|
|
ds.Write(bytes, 0, bytes.Length);
|
|
}
|
|
}
|
|
catch { }
|
|
try { dataListener.Stop(); } catch { }
|
|
dataListener = null;
|
|
SendLine(stream, "226 Transfer complete");
|
|
break;
|
|
|
|
case "STOR":
|
|
if (dataListener == null) { SendLine(stream, "425 No data connection"); break; }
|
|
string storPath = NormalizePath(cwd, arg);
|
|
SendLine(stream, "150 Opening data connection");
|
|
var sb = new StringBuilder();
|
|
try
|
|
{
|
|
using (var dc = dataListener.AcceptTcpClient())
|
|
using (var ds = dc.GetStream())
|
|
using (var dr = new StreamReader(ds, Encoding.ASCII))
|
|
{
|
|
sb.Append(dr.ReadToEnd());
|
|
}
|
|
}
|
|
catch { }
|
|
Files[storPath] = sb.ToString();
|
|
try { dataListener.Stop(); } catch { }
|
|
dataListener = null;
|
|
SendLine(stream, "226 Transfer complete");
|
|
break;
|
|
|
|
case "DELE":
|
|
string delePath = NormalizePath(cwd, arg);
|
|
Files.Remove(delePath);
|
|
SendLine(stream, "250 DELE successful");
|
|
break;
|
|
|
|
case "QUIT":
|
|
SendLine(stream, "221 Goodbye");
|
|
return;
|
|
|
|
default:
|
|
SendLine(stream, "500 Unknown command");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string NormalizePath(string cwd, string arg)
|
|
{
|
|
if (arg.StartsWith("/")) return arg;
|
|
return cwd.TrimEnd('/') + "/" + arg;
|
|
}
|
|
}
|
|
}
|