From 5ce79971cf2b478f3f7af3b7bbdd7c6517967664 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 28 Oct 2025 23:55:39 +0300 Subject: [PATCH 01/13] add: server part --- SimpleFTP/Server/LogicOfServer.cs | 82 ++++++++++++++++++++ SimpleFTP/Server/Program.cs | 5 ++ SimpleFTP/Server/Server.cs | 95 ++++++++++++++++++++++++ SimpleFTP/Server/SimpleFTP.Server.csproj | 17 +++++ SimpleFTP/SimpleFTP.sln | 31 ++++++++ 5 files changed, 230 insertions(+) create mode 100644 SimpleFTP/Server/LogicOfServer.cs create mode 100644 SimpleFTP/Server/Program.cs create mode 100644 SimpleFTP/Server/Server.cs create mode 100644 SimpleFTP/Server/SimpleFTP.Server.csproj create mode 100644 SimpleFTP/SimpleFTP.sln diff --git a/SimpleFTP/Server/LogicOfServer.cs b/SimpleFTP/Server/LogicOfServer.cs new file mode 100644 index 0000000..e50299f --- /dev/null +++ b/SimpleFTP/Server/LogicOfServer.cs @@ -0,0 +1,82 @@ +using System.Text; + +namespace Server +{ + public class LogicOfServer + { + private string baseDirectory; + private string fullPath; + + public LogicOfServer(string baseDirectory) + { + this.baseDirectory = baseDirectory; + } + + public bool IsPathSafe(string requestedPath, string fullPath) + { + this.fullPath = Path.GetFullPath(this.baseDirectory, requestedPath); + return fullPath.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase); + } + + public async Task List(StreamWriter streamwriter, string directoryPath) + { + DirectoryInfo infoAboutDirectory = new DirectoryInfo(directoryPath); + + if (!infoAboutDirectory.Exists) + { + await streamwriter.WriteLineAsync("-1"); + return; + } + + try + { + FileSystemInfo[] filesAndFolders = infoAboutDirectory.GetFileSystemInfos(); + StringBuilder response = new StringBuilder(); + + response.Append(filesAndFolders.Length); + + foreach (var data in filesAndFolders) + { + response.Append($"{data.Name} {(data is FileInfo ? "true" : "false")}"); + } + + await streamwriter.WriteLineAsync(response.ToString()); + } + catch (Exception exception) + { + Console.WriteLine($"Error in 'List': {exception.Message}"); + await streamwriter.WriteLineAsync("-1"); + } + } + + public async Task Get(Stream stream, string filePath) + { + FileInfo file = new FileInfo(filePath); + + if (!file.Exists) + { + byte[] errorInBytes = BitConverter.GetBytes(-1L); + await stream.WriteAsync(errorInBytes, 0, errorInBytes.Length); + return; + } + + try + { + long fileSize = file.Length; + byte[] sizeBytes = BitConverter.GetBytes(fileSize); + await stream.WriteAsync(sizeBytes, 0, sizeBytes.Length); + + await using (FileStream fileStream = file.OpenRead()) + { + await fileStream.CopyToAsync(stream); + } + + Console.WriteLine($"Sent file ({filePath}) ({fileSize} bytes)"); + } + catch (Exception exception) + { + Console.WriteLine($"Error in 'Get': {exception.Message}"); + } + } + } +} \ No newline at end of file diff --git a/SimpleFTP/Server/Program.cs b/SimpleFTP/Server/Program.cs new file mode 100644 index 0000000..81994da --- /dev/null +++ b/SimpleFTP/Server/Program.cs @@ -0,0 +1,5 @@ +using Server; + +string baseDirectory = Directory.GetCurrentDirectory(); +ServerClass fileServer = new ServerClass(baseDirectory); +await fileServer.Start(); \ No newline at end of file diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs new file mode 100644 index 0000000..4253625 --- /dev/null +++ b/SimpleFTP/Server/Server.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Server +{ + public class ServerClass + { + private const int Port = 8888; + private readonly TcpListener listener; + private readonly LogicOfServer logicOfServer; + + public ServerClass(string baseDirectory) + { + this.listener = new TcpListener(IPAddress.Any, Port); + this.logicOfServer = new LogicOfServer(baseDirectory); + } + + public async Task Start() + { + this.listener.Start(); + Console.WriteLine($"Listening on port {Port}..."); + + while (true) + { + try + { + TcpClient client = await this.listener.AcceptTcpClientAsync(); + Task clientTask = Task.Run(() => this.ClientHandler(client)); + } + catch (Exception exception) + { + Console.WriteLine($"Error accepting client {exception.Message}"); + } + } + } + + private async Task ClientHandler(TcpClient client) + { + Console.WriteLine("Client connected"); + try + { + await using (NetworkStream stream = client.GetStream()) + using (var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true)) + await using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + string line = string.Empty; + while ((line = await reader.ReadLineAsync()) != null) + { + Console.WriteLine($"Request: {line}"); + string[] parts = line.Split(' ', 2); + if (parts.Length < 2) + { + continue; + } + + string command = parts[0]; + string path = parts[1]; + + string fullPath = string.Empty; + if (!this.logicOfServer.IsPathSafe(path, fullPath)) + { + Console.WriteLine($"Wrong path {fullPath}"); + await writer.WriteLineAsync("-1"); + continue; + } + + switch (command) + { + case "1": + await this.logicOfServer.List(writer, fullPath); + break; + case "2": + await writer.FlushAsync(); + await this.logicOfServer.Get(stream, fullPath); + break; + default: + await writer.WriteLineAsync("-1"); + break; + } + } + } + } + catch(Exception exception) + { + Console.WriteLine($"Error handling client {exception.Message}"); + } + finally + { + client.Close(); + Console.WriteLine("Client disconnected"); + } + } + } +} \ No newline at end of file diff --git a/SimpleFTP/Server/SimpleFTP.Server.csproj b/SimpleFTP/Server/SimpleFTP.Server.csproj new file mode 100644 index 0000000..90dd746 --- /dev/null +++ b/SimpleFTP/Server/SimpleFTP.Server.csproj @@ -0,0 +1,17 @@ + + + + Exe + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/SimpleFTP/SimpleFTP.sln b/SimpleFTP/SimpleFTP.sln new file mode 100644 index 0000000..4a1976d --- /dev/null +++ b/SimpleFTP/SimpleFTP.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35806.99 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Server", "Server\SimpleFTP.Server.csproj", "{4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Client", "Client\SimpleFTP.Client.csproj", "{A1377BAB-DB67-4A74-A7D0-86B07890FC3F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Release|Any CPU.Build.0 = Release|Any CPU + {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F88013D2-30E3-4487-AFAE-0A91EEBCAB90} + EndGlobalSection +EndGlobal From 22a344b59217f73b4edb8bb4b35af7a6c06a138f Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 29 Oct 2025 00:00:46 +0300 Subject: [PATCH 02/13] add: csproj --- SimpleFTP/SimpleFTP/SimpleFTP.csproj | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 SimpleFTP/SimpleFTP/SimpleFTP.csproj diff --git a/SimpleFTP/SimpleFTP/SimpleFTP.csproj b/SimpleFTP/SimpleFTP/SimpleFTP.csproj new file mode 100644 index 0000000..229668d --- /dev/null +++ b/SimpleFTP/SimpleFTP/SimpleFTP.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + From d0094cf7bbc29ce67e5f16f8d87cf03ef490aac5 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 29 Oct 2025 05:33:26 +0300 Subject: [PATCH 03/13] fix: critical errors in the server logic --- SimpleFTP/Server/LogicOfServer.cs | 19 ++++++++++-------- SimpleFTP/Server/Program.cs | 1 + SimpleFTP/Server/Server.cs | 30 +++++++++++++++++++--------- SimpleFTP/SimpleFTP.sln | 24 ++++++++++++++++------ SimpleFTP/SimpleFTP/SimpleFTP.csproj | 6 +----- 5 files changed, 52 insertions(+), 28 deletions(-) diff --git a/SimpleFTP/Server/LogicOfServer.cs b/SimpleFTP/Server/LogicOfServer.cs index e50299f..e4c3fbe 100644 --- a/SimpleFTP/Server/LogicOfServer.cs +++ b/SimpleFTP/Server/LogicOfServer.cs @@ -1,21 +1,24 @@ -using System.Text; +using System.IO; +using System.Text; namespace Server { public class LogicOfServer { private string baseDirectory; - private string fullPath; public LogicOfServer(string baseDirectory) { - this.baseDirectory = baseDirectory; + this.baseDirectory = Path.GetFullPath(baseDirectory); } - public bool IsPathSafe(string requestedPath, string fullPath) + public bool IsPathSafe(string requestedPath, out string? fullPath) { - this.fullPath = Path.GetFullPath(this.baseDirectory, requestedPath); + + string combinedPath = Path.Combine(this.baseDirectory, requestedPath); + fullPath = Path.GetFullPath(combinedPath); return fullPath.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase); + } public async Task List(StreamWriter streamwriter, string directoryPath) @@ -37,7 +40,7 @@ public async Task List(StreamWriter streamwriter, string directoryPath) foreach (var data in filesAndFolders) { - response.Append($"{data.Name} {(data is FileInfo ? "true" : "false")}"); + response.Append($" {data.Name} {(data is DirectoryInfo ? "true" : "false")}"); } await streamwriter.WriteLineAsync(response.ToString()); @@ -56,7 +59,7 @@ public async Task Get(Stream stream, string filePath) if (!file.Exists) { byte[] errorInBytes = BitConverter.GetBytes(-1L); - await stream.WriteAsync(errorInBytes, 0, errorInBytes.Length); + await stream.WriteAsync(errorInBytes); return; } @@ -64,7 +67,7 @@ public async Task Get(Stream stream, string filePath) { long fileSize = file.Length; byte[] sizeBytes = BitConverter.GetBytes(fileSize); - await stream.WriteAsync(sizeBytes, 0, sizeBytes.Length); + await stream.WriteAsync(sizeBytes); await using (FileStream fileStream = file.OpenRead()) { diff --git a/SimpleFTP/Server/Program.cs b/SimpleFTP/Server/Program.cs index 81994da..b6ea7a0 100644 --- a/SimpleFTP/Server/Program.cs +++ b/SimpleFTP/Server/Program.cs @@ -1,5 +1,6 @@ using Server; string baseDirectory = Directory.GetCurrentDirectory(); +Console.WriteLine($"Starting server. Base directory: {baseDirectory}"); ServerClass fileServer = new ServerClass(baseDirectory); await fileServer.Start(); \ No newline at end of file diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index 4253625..72a51be 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -40,11 +40,11 @@ private async Task ClientHandler(TcpClient client) Console.WriteLine("Client connected"); try { - await using (NetworkStream stream = client.GetStream()) - using (var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true)) - await using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); { - string line = string.Empty; + string? line = string.Empty; while ((line = await reader.ReadLineAsync()) != null) { Console.WriteLine($"Request: {line}"); @@ -57,11 +57,21 @@ private async Task ClientHandler(TcpClient client) string command = parts[0]; string path = parts[1]; - string fullPath = string.Empty; - if (!this.logicOfServer.IsPathSafe(path, fullPath)) + if (!this.logicOfServer.IsPathSafe(path, out string? fullPath) || fullPath == null) { - Console.WriteLine($"Wrong path {fullPath}"); - await writer.WriteLineAsync("-1"); + Console.WriteLine($"access denied {path}"); + switch (command) + { + case "1": + await writer.WriteLineAsync("-1"); + break; + case "2": + await writer.FlushAsync(); + byte[] errorBytes = BitConverter.GetBytes(-1L); + await stream.WriteAsync(errorBytes); + break; + } + continue; } @@ -78,10 +88,12 @@ private async Task ClientHandler(TcpClient client) await writer.WriteLineAsync("-1"); break; } + + await writer.FlushAsync(); } } } - catch(Exception exception) + catch (Exception exception) { Console.WriteLine($"Error handling client {exception.Message}"); } diff --git a/SimpleFTP/SimpleFTP.sln b/SimpleFTP/SimpleFTP.sln index 4a1976d..d12ff67 100644 --- a/SimpleFTP/SimpleFTP.sln +++ b/SimpleFTP/SimpleFTP.sln @@ -3,24 +3,36 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.13.35806.99 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Server", "Server\SimpleFTP.Server.csproj", "{4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Client", "Client\SimpleFTP.Client.csproj", "{A1377BAB-DB67-4A74-A7D0-86B07890FC3F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Tests", "SimpleFTP.Tests\SimpleFTP.Tests.csproj", "{CE04E890-DE80-431B-B53E-07A74FA6AEF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP", "SimpleFTP\SimpleFTP.csproj", "{1B9979DB-E126-40BB-B693-2E5BAE60DAF3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleFTP.Server", "Server\SimpleFTP.Server.csproj", "{FDD87F48-4BC5-B87E-9D30-B23C96243D6D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4239A9AE-A5B2-4A0C-98D7-8F2E31CCC1F3}.Release|Any CPU.Build.0 = Release|Any CPU {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1377BAB-DB67-4A74-A7D0-86B07890FC3F}.Release|Any CPU.Build.0 = Release|Any CPU + {CE04E890-DE80-431B-B53E-07A74FA6AEF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE04E890-DE80-431B-B53E-07A74FA6AEF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE04E890-DE80-431B-B53E-07A74FA6AEF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE04E890-DE80-431B-B53E-07A74FA6AEF9}.Release|Any CPU.Build.0 = Release|Any CPU + {1B9979DB-E126-40BB-B693-2E5BAE60DAF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B9979DB-E126-40BB-B693-2E5BAE60DAF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B9979DB-E126-40BB-B693-2E5BAE60DAF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B9979DB-E126-40BB-B693-2E5BAE60DAF3}.Release|Any CPU.Build.0 = Release|Any CPU + {FDD87F48-4BC5-B87E-9D30-B23C96243D6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDD87F48-4BC5-B87E-9D30-B23C96243D6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDD87F48-4BC5-B87E-9D30-B23C96243D6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDD87F48-4BC5-B87E-9D30-B23C96243D6D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SimpleFTP/SimpleFTP/SimpleFTP.csproj b/SimpleFTP/SimpleFTP/SimpleFTP.csproj index 229668d..3b8f954 100644 --- a/SimpleFTP/SimpleFTP/SimpleFTP.csproj +++ b/SimpleFTP/SimpleFTP/SimpleFTP.csproj @@ -1,14 +1,10 @@  - Exe + Library net9.0 enable enable - - - - From 0cba9cc1024823a35f33f78c2f10a082220af83f Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 29 Oct 2025 05:34:30 +0300 Subject: [PATCH 04/13] add: logic for the client --- SimpleFTP/Client/Client.cs | 162 +++++++++++++++++++++++ SimpleFTP/Client/Program.cs | 3 + SimpleFTP/Client/SimpleFTP.Client.csproj | 17 +++ 3 files changed, 182 insertions(+) create mode 100644 SimpleFTP/Client/Client.cs create mode 100644 SimpleFTP/Client/Program.cs create mode 100644 SimpleFTP/Client/SimpleFTP.Client.csproj diff --git a/SimpleFTP/Client/Client.cs b/SimpleFTP/Client/Client.cs new file mode 100644 index 0000000..1e5db29 --- /dev/null +++ b/SimpleFTP/Client/Client.cs @@ -0,0 +1,162 @@ +using System.Net.Sockets; +using System.Text; + +namespace SimpleFTP.Client +{ + public class Client + { + private const string ip = "127.0.0.1"; + private const int port = 8888; + + public static async Task StartClient() + { + using (TcpClient client = new TcpClient()) + { + try + { + await client.ConnectAsync(ip, port); + Console.WriteLine($"Connected at {ip}"); + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + await CommandHandler(stream, reader, writer); + } + catch (Exception excetpion) + { + Console.WriteLine($"Error. {excetpion.Message}"); + } + } + + Console.WriteLine("Disconnected from the server"); + } + + private static async Task CommandHandler(NetworkStream stream, StreamReader reader, StreamWriter writer) + { + Console.WriteLine("Enter commands ('list ' or 'get ' or 'exit'"); + while (true) + { + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + string[] parts = input.Split(' '); + string command = parts[0].ToLower(); + + if (command == "exit") + { + break; + } + + switch (command) + { + case "list": + if (parts.Length == 2) + { + await HandleListRequest(reader, writer, parts[1]); + } + else + { + Console.WriteLine("Incorrect input. Enter 'list '"); + } + + break; + case "get": + if (parts.Length == 3) + { + await HandleGetRequest(stream, writer, parts[1], parts[2]); + } + else + { + Console.WriteLine("Incorrect input. Enter 'get '"); + } + + break; + + default: + Console.WriteLine("Unknown command"); + break; + } + } + } + + private static async Task HandleListRequest(StreamReader reader, StreamWriter writer, string path) + { + await writer.WriteLineAsync($"1 {path}"); + await writer.FlushAsync(); + + string? response = await reader.ReadLineAsync(); + if (response == "-1") + { + Console.WriteLine("Error. Directory was not found or access to it was denied"); + return; + } + + string[] parts = response.Split(' '); + if (!int.TryParse(parts[0], out int count) || count < 0) + { + Console.WriteLine("invalid response from the server"); + return; + } + + if (count == 0) + { + Console.WriteLine("Directory is empty"); + return; + } + + Console.WriteLine($"Directory contains {count} items:"); + for (int i = 0; i < count; ++i) + { + string name = parts[(i * 2) + 1]; + string isDirectory = parts[(i * 2) + 2] == "true" ? "[dir]" : "[file]"; + Console.WriteLine($" {isDirectory} {name}"); + } + } + + private static async Task HandleGetRequest(NetworkStream stream, StreamWriter writer, string path, string localPath) + { + await writer.WriteLineAsync($"2 {path}"); + await writer.FlushAsync(); + byte[] sizeBuffer = new byte[8]; + await stream.ReadExactlyAsync(sizeBuffer, 0, 8); + long fileSize = BitConverter.ToInt64(sizeBuffer, 0); + + if (fileSize == -1) + { + Console.WriteLine("File not found or access denied"); + return; + } + + Console.WriteLine($"Downloading file {path} to {localPath}"); + long totalBytesRead = 0; + + try + { + await using (FileStream fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write)) + { + byte[] buffer = new byte[81920]; + while (totalBytesRead < fileSize) + { + int bytesToRead = (int)Math.Min(fileSize - totalBytesRead, buffer.Length); + int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead)); + + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); + totalBytesRead += bytesRead; + } + } + + Console.WriteLine("Download finish"); + } + catch (Exception excetpion) + { + Console.WriteLine($"Error loading. {excetpion.Message}"); + if (File.Exists(localPath)) + { + File.Delete(localPath); + } + } + } + } +} \ No newline at end of file diff --git a/SimpleFTP/Client/Program.cs b/SimpleFTP/Client/Program.cs new file mode 100644 index 0000000..a973059 --- /dev/null +++ b/SimpleFTP/Client/Program.cs @@ -0,0 +1,3 @@ +using SimpleFTP.Client; + +await Client.StartClient(); \ No newline at end of file diff --git a/SimpleFTP/Client/SimpleFTP.Client.csproj b/SimpleFTP/Client/SimpleFTP.Client.csproj new file mode 100644 index 0000000..90dd746 --- /dev/null +++ b/SimpleFTP/Client/SimpleFTP.Client.csproj @@ -0,0 +1,17 @@ + + + + Exe + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + From b6be4754887e2aac8d38d34ea5b39ab105ef12dc Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 29 Oct 2025 05:35:28 +0300 Subject: [PATCH 05/13] add: test project --- .../SimpleFTP.Tests/ClientParsingTests.cs | 10 +++++++ .../SimpleFTP.Tests/SimpleFTP.Tests.csproj | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs create mode 100644 SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj diff --git a/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs b/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs new file mode 100644 index 0000000..1a325c5 --- /dev/null +++ b/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs @@ -0,0 +1,10 @@ +namespace SimpleFTP.Tests; + +public class ClientParsingTests +{ + [Test] + public void List_EmptyDirectory_ShouldBehaveProperly() + { + Assert.Pass(); + } +} \ No newline at end of file diff --git a/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj b/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj new file mode 100644 index 0000000..2d80f56 --- /dev/null +++ b/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + From 50de4d21d5f9d43184bcf1e12ce2130905e33466 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sat, 1 Nov 2025 02:27:32 +0300 Subject: [PATCH 06/13] refactor: changes in accordance with stylecop --- SimpleFTP/Client/.editorconfig | 134 +++++++++ SimpleFTP/Client/Client.cs | 260 +++++++++--------- SimpleFTP/Client/Program.cs | 6 +- SimpleFTP/Client/SimpleFTP.Client.csproj | 4 + SimpleFTP/Client/stylecop.json | 8 + SimpleFTP/Server/.editorconfig | 116 ++++++++ SimpleFTP/Server/LogicOfServer.cs | 143 ++++++---- SimpleFTP/Server/Program.cs | 8 +- SimpleFTP/Server/Server.cs | 162 ++++++----- SimpleFTP/Server/SimpleFTP.Server.csproj | 4 + SimpleFTP/Server/stylecop.json | 8 + SimpleFTP/SimpleFTP.Tests/.editorconfig | 119 ++++++++ .../SimpleFTP.Tests/ClientParsingTests.cs | 6 +- .../SimpleFTP.Tests/SimpleFTP.Tests.csproj | 18 +- SimpleFTP/SimpleFTP.Tests/stylecop.json | 8 + 15 files changed, 737 insertions(+), 267 deletions(-) create mode 100644 SimpleFTP/Client/.editorconfig create mode 100644 SimpleFTP/Client/stylecop.json create mode 100644 SimpleFTP/Server/.editorconfig create mode 100644 SimpleFTP/Server/stylecop.json create mode 100644 SimpleFTP/SimpleFTP.Tests/.editorconfig create mode 100644 SimpleFTP/SimpleFTP.Tests/stylecop.json diff --git a/SimpleFTP/Client/.editorconfig b/SimpleFTP/Client/.editorconfig new file mode 100644 index 0000000..6cd746e --- /dev/null +++ b/SimpleFTP/Client/.editorconfig @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleFTP.Client +{ + class _ + { + } +} + +[*.cs] +dotnet_diagnostic.SA0001.severity = none + +[*.cs] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Стили именования + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +csharp_indent_labels = one_less_than_current +csharp_style_throw_expression = true:suggestion + +[*.vb] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_начинается_с_i.severity = suggestion +dotnet_naming_rule.interface_should_be_начинается_с_i.symbols = interface +dotnet_naming_rule.interface_should_be_начинается_с_i.style = начинается_с_i + +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.symbols = типы +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.symbols = не_являющиеся_полем_члены +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.типы.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.типы.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.типы.required_modifiers = + +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_kinds = property, event, method +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.не_являющиеся_полем_члены.required_modifiers = + +# Стили именования + +dotnet_naming_style.начинается_с_i.required_prefix = I +dotnet_naming_style.начинается_с_i.required_suffix = +dotnet_naming_style.начинается_с_i.word_separator = +dotnet_naming_style.начинается_с_i.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +[*.{cs,vb}] +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 \ No newline at end of file diff --git a/SimpleFTP/Client/Client.cs b/SimpleFTP/Client/Client.cs index 1e5db29..a6f3e87 100644 --- a/SimpleFTP/Client/Client.cs +++ b/SimpleFTP/Client/Client.cs @@ -1,161 +1,171 @@ -using System.Net.Sockets; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +namespace SimpleFTP.Client; + +using System.Net.Sockets; using System.Text; -namespace SimpleFTP.Client +/// +/// A class that implements the client's functionality. +/// +public class Client { - public class Client + private const string IP = "127.0.0.1"; + private const int PORT = 8888; + + /// + /// Launches the client and connects to the server. + /// + /// Task representing the operation. + public static async Task StartClient() { - private const string ip = "127.0.0.1"; - private const int port = 8888; - - public static async Task StartClient() + using (TcpClient client = new TcpClient()) { - using (TcpClient client = new TcpClient()) + try { - try - { - await client.ConnectAsync(ip, port); - Console.WriteLine($"Connected at {ip}"); - await using NetworkStream stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); - await CommandHandler(stream, reader, writer); - } - catch (Exception excetpion) - { - Console.WriteLine($"Error. {excetpion.Message}"); - } + await client.ConnectAsync(IP, PORT); + Console.WriteLine($"Connected at {IP}"); + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + await CommandHandler(stream, reader, writer); } - - Console.WriteLine("Disconnected from the server"); - } - - private static async Task CommandHandler(NetworkStream stream, StreamReader reader, StreamWriter writer) - { - Console.WriteLine("Enter commands ('list ' or 'get ' or 'exit'"); - while (true) + catch (Exception excetpion) { - string? input = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(input)) - { - continue; - } - - string[] parts = input.Split(' '); - string command = parts[0].ToLower(); - - if (command == "exit") - { - break; - } - - switch (command) - { - case "list": - if (parts.Length == 2) - { - await HandleListRequest(reader, writer, parts[1]); - } - else - { - Console.WriteLine("Incorrect input. Enter 'list '"); - } - - break; - case "get": - if (parts.Length == 3) - { - await HandleGetRequest(stream, writer, parts[1], parts[2]); - } - else - { - Console.WriteLine("Incorrect input. Enter 'get '"); - } - - break; - - default: - Console.WriteLine("Unknown command"); - break; - } + Console.WriteLine($"Error. {excetpion.Message}"); } } - private static async Task HandleListRequest(StreamReader reader, StreamWriter writer, string path) - { - await writer.WriteLineAsync($"1 {path}"); - await writer.FlushAsync(); + Console.WriteLine("Disconnected from the server"); + } - string? response = await reader.ReadLineAsync(); - if (response == "-1") + private static async Task CommandHandler(NetworkStream stream, StreamReader reader, StreamWriter writer) + { + Console.WriteLine("Enter commands ('list ' or 'get ' or 'exit'"); + while (true) + { + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) { - Console.WriteLine("Error. Directory was not found or access to it was denied"); - return; + continue; } - string[] parts = response.Split(' '); - if (!int.TryParse(parts[0], out int count) || count < 0) - { - Console.WriteLine("invalid response from the server"); - return; - } + string[] parts = input.Split(' '); + string command = parts[0].ToLower(); - if (count == 0) + if (command == "exit") { - Console.WriteLine("Directory is empty"); - return; + break; } - Console.WriteLine($"Directory contains {count} items:"); - for (int i = 0; i < count; ++i) + switch (command) { - string name = parts[(i * 2) + 1]; - string isDirectory = parts[(i * 2) + 2] == "true" ? "[dir]" : "[file]"; - Console.WriteLine($" {isDirectory} {name}"); + case "list": + if (parts.Length == 2) + { + await HandleListRequest(reader, writer, parts[1]); + } + else + { + Console.WriteLine("Incorrect input. Enter 'list '"); + } + + break; + case "get": + if (parts.Length == 3) + { + await HandleGetRequest(stream, writer, parts[1], parts[2]); + } + else + { + Console.WriteLine("Incorrect input. Enter 'get '"); + } + + break; + + default: + Console.WriteLine("Unknown command"); + break; } } + } - private static async Task HandleGetRequest(NetworkStream stream, StreamWriter writer, string path, string localPath) + private static async Task HandleListRequest(StreamReader reader, StreamWriter writer, string path) + { + await writer.WriteLineAsync($"1 {path}"); + await writer.FlushAsync(); + + string? response = await reader.ReadLineAsync(); + if (response == "-1" || response is null) { - await writer.WriteLineAsync($"2 {path}"); - await writer.FlushAsync(); - byte[] sizeBuffer = new byte[8]; - await stream.ReadExactlyAsync(sizeBuffer, 0, 8); - long fileSize = BitConverter.ToInt64(sizeBuffer, 0); + Console.WriteLine("Error. Directory was not found or access to it was denied"); + return; + } - if (fileSize == -1) - { - Console.WriteLine("File not found or access denied"); - return; - } + string[] parts = response!.Split(' '); + if (!int.TryParse(parts[0], out int count) || count < 0) + { + Console.WriteLine("invalid response from the server"); + return; + } - Console.WriteLine($"Downloading file {path} to {localPath}"); - long totalBytesRead = 0; + if (count == 0) + { + Console.WriteLine("Directory is empty"); + return; + } - try + Console.WriteLine($"Directory contains {count} items:"); + for (int i = 0; i < count; ++i) + { + string name = parts[(i * 2) + 1]; + string isDirectory = parts[(i * 2) + 2] == "true" ? "[dir]" : "[file]"; + Console.WriteLine($" {isDirectory} {name}"); + } + } + + private static async Task HandleGetRequest(NetworkStream stream, StreamWriter writer, string path, string localPath) + { + await writer.WriteLineAsync($"2 {path}"); + await writer.FlushAsync(); + byte[] sizeBuffer = new byte[8]; + await stream.ReadExactlyAsync(sizeBuffer, 0, 8); + long fileSize = BitConverter.ToInt64(sizeBuffer, 0); + + if (fileSize == -1) + { + Console.WriteLine("File not found or access denied"); + return; + } + + Console.WriteLine($"Downloading file {path} to {localPath}"); + long totalBytesRead = 0; + + try + { + await using (FileStream fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write)) { - await using (FileStream fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write)) + byte[] buffer = new byte[81920]; + while (totalBytesRead < fileSize) { - byte[] buffer = new byte[81920]; - while (totalBytesRead < fileSize) - { - int bytesToRead = (int)Math.Min(fileSize - totalBytesRead, buffer.Length); - int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead)); + int bytesToRead = (int)Math.Min(fileSize - totalBytesRead, buffer.Length); + int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead)); - await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); - totalBytesRead += bytesRead; - } + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); + totalBytesRead += bytesRead; } - - Console.WriteLine("Download finish"); } - catch (Exception excetpion) + + Console.WriteLine("Download finish"); + } + catch (Exception excetpion) + { + Console.WriteLine($"Error loading. {excetpion.Message}"); + if (File.Exists(localPath)) { - Console.WriteLine($"Error loading. {excetpion.Message}"); - if (File.Exists(localPath)) - { - File.Delete(localPath); - } + File.Delete(localPath); } } } diff --git a/SimpleFTP/Client/Program.cs b/SimpleFTP/Client/Program.cs index a973059..e1cb7af 100644 --- a/SimpleFTP/Client/Program.cs +++ b/SimpleFTP/Client/Program.cs @@ -1,3 +1,7 @@ -using SimpleFTP.Client; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +using SimpleFTP.Client; await Client.StartClient(); \ No newline at end of file diff --git a/SimpleFTP/Client/SimpleFTP.Client.csproj b/SimpleFTP/Client/SimpleFTP.Client.csproj index 90dd746..d764014 100644 --- a/SimpleFTP/Client/SimpleFTP.Client.csproj +++ b/SimpleFTP/Client/SimpleFTP.Client.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/SimpleFTP/Client/stylecop.json b/SimpleFTP/Client/stylecop.json new file mode 100644 index 0000000..c7ed419 --- /dev/null +++ b/SimpleFTP/Client/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "Kalinin Andrew" + } + } +} diff --git a/SimpleFTP/Server/.editorconfig b/SimpleFTP/Server/.editorconfig new file mode 100644 index 0000000..0e1810b --- /dev/null +++ b/SimpleFTP/Server/.editorconfig @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleFTP.Server +{ + class _ + { + } +} + +[*.cs] +dotnet_diagnostic.SA0001.severity = none + +[*.cs] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Стили именования + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +[*.vb] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_начинается_с_i.severity = suggestion +dotnet_naming_rule.interface_should_be_начинается_с_i.symbols = interface +dotnet_naming_rule.interface_should_be_начинается_с_i.style = начинается_с_i + +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.symbols = типы +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.symbols = не_являющиеся_полем_члены +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.типы.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.типы.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.типы.required_modifiers = + +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_kinds = property, event, method +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.не_являющиеся_полем_члены.required_modifiers = + +# Стили именования + +dotnet_naming_style.начинается_с_i.required_prefix = I +dotnet_naming_style.начинается_с_i.required_suffix = +dotnet_naming_style.начинается_с_i.word_separator = +dotnet_naming_style.начинается_с_i.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 \ No newline at end of file diff --git a/SimpleFTP/Server/LogicOfServer.cs b/SimpleFTP/Server/LogicOfServer.cs index e4c3fbe..cb29662 100644 --- a/SimpleFTP/Server/LogicOfServer.cs +++ b/SimpleFTP/Server/LogicOfServer.cs @@ -1,85 +1,110 @@ -using System.IO; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +namespace SimpleFTP.Server; + using System.Text; -namespace Server +/// +/// A class with query processing logic. +/// +public class LogicOfServer { - public class LogicOfServer + private string baseDirectory; + + /// + /// Initializes a new instance of the class. + /// + /// The base directory for which requests will be processed. + public LogicOfServer(string baseDirectory) { - private string baseDirectory; + this.baseDirectory = Path.GetFullPath(baseDirectory); + } - public LogicOfServer(string baseDirectory) - { - this.baseDirectory = Path.GetFullPath(baseDirectory); - } + /// + /// Checks whether the requested path is located inside the base directory. + /// + /// The path from the client's request. + /// Full path. + /// True if the path is safe and located inside the base directory, otherwise False. + public bool IsPathSafe(string requestedPath, out string? fullPath) + { + string combinedPath = Path.Combine(this.baseDirectory, requestedPath); + fullPath = Path.GetFullPath(combinedPath); + return fullPath.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase); + } - public bool IsPathSafe(string requestedPath, out string? fullPath) - { + /// + /// Processes the List command, compiles a list of files/folders and sends it to the client. + /// + /// StreamWriter for sending a text response. + /// The full path to the directory for listing. + /// Task representing the operation. + public async Task List(StreamWriter streamwriter, string directoryPath) + { + DirectoryInfo infoAboutDirectory = new DirectoryInfo(directoryPath); - string combinedPath = Path.Combine(this.baseDirectory, requestedPath); - fullPath = Path.GetFullPath(combinedPath); - return fullPath.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase); - + if (!infoAboutDirectory.Exists) + { + await streamwriter.WriteLineAsync("-1"); + return; } - public async Task List(StreamWriter streamwriter, string directoryPath) + try { - DirectoryInfo infoAboutDirectory = new DirectoryInfo(directoryPath); + FileSystemInfo[] filesAndFolders = infoAboutDirectory.GetFileSystemInfos(); + StringBuilder response = new StringBuilder(); - if (!infoAboutDirectory.Exists) - { - await streamwriter.WriteLineAsync("-1"); - return; - } + response.Append(filesAndFolders.Length); - try + foreach (var data in filesAndFolders) { - FileSystemInfo[] filesAndFolders = infoAboutDirectory.GetFileSystemInfos(); - StringBuilder response = new StringBuilder(); + response.Append($" {data.Name} {(data is DirectoryInfo ? "true" : "false")}"); + } - response.Append(filesAndFolders.Length); + await streamwriter.WriteLineAsync(response.ToString()); + } + catch (Exception exception) + { + Console.WriteLine($"Error in 'List': {exception.Message}"); + await streamwriter.WriteLineAsync("-1"); + } + } - foreach (var data in filesAndFolders) - { - response.Append($" {data.Name} {(data is DirectoryInfo ? "true" : "false")}"); - } + /// + /// Processes the Get command, sends the file size and its contents to the client. + /// + /// A network stream for sending data. + /// The full path to the download file. + /// Task representing the operation. + public async Task Get(Stream stream, string filePath) + { + FileInfo file = new FileInfo(filePath); - await streamwriter.WriteLineAsync(response.ToString()); - } - catch (Exception exception) - { - Console.WriteLine($"Error in 'List': {exception.Message}"); - await streamwriter.WriteLineAsync("-1"); - } + if (!file.Exists) + { + byte[] errorInBytes = BitConverter.GetBytes(-1L); + await stream.WriteAsync(errorInBytes); + return; } - public async Task Get(Stream stream, string filePath) + try { - FileInfo file = new FileInfo(filePath); + long fileSize = file.Length; + byte[] sizeBytes = BitConverter.GetBytes(fileSize); + await stream.WriteAsync(sizeBytes); - if (!file.Exists) + await using (FileStream fileStream = file.OpenRead()) { - byte[] errorInBytes = BitConverter.GetBytes(-1L); - await stream.WriteAsync(errorInBytes); - return; + await fileStream.CopyToAsync(stream); } - try - { - long fileSize = file.Length; - byte[] sizeBytes = BitConverter.GetBytes(fileSize); - await stream.WriteAsync(sizeBytes); - - await using (FileStream fileStream = file.OpenRead()) - { - await fileStream.CopyToAsync(stream); - } - - Console.WriteLine($"Sent file ({filePath}) ({fileSize} bytes)"); - } - catch (Exception exception) - { - Console.WriteLine($"Error in 'Get': {exception.Message}"); - } + Console.WriteLine($"Sent file ({filePath}) ({fileSize} bytes)"); + } + catch (Exception exception) + { + Console.WriteLine($"Error in 'Get': {exception.Message}"); } } } \ No newline at end of file diff --git a/SimpleFTP/Server/Program.cs b/SimpleFTP/Server/Program.cs index b6ea7a0..7e93339 100644 --- a/SimpleFTP/Server/Program.cs +++ b/SimpleFTP/Server/Program.cs @@ -1,6 +1,10 @@ -using Server; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +using SimpleFTP.Server; string baseDirectory = Directory.GetCurrentDirectory(); Console.WriteLine($"Starting server. Base directory: {baseDirectory}"); -ServerClass fileServer = new ServerClass(baseDirectory); +Server fileServer = new Server(baseDirectory); await fileServer.Start(); \ No newline at end of file diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index 72a51be..aa8bf6c 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -1,107 +1,121 @@ -using System.Net; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +namespace SimpleFTP.Server; + +using System.Net; using System.Net.Sockets; using System.Text; -namespace Server +/// +/// The main server class that manages listening for connections and client processing. +/// +public class Server { - public class ServerClass + private const int Port = 8888; + private readonly TcpListener listener; + private readonly LogicOfServer logicOfServer; + + /// + /// Initializes a new instance of the class. + /// + /// The base directory for file operations. + public Server(string baseDirectory) { - private const int Port = 8888; - private readonly TcpListener listener; - private readonly LogicOfServer logicOfServer; + this.listener = new TcpListener(IPAddress.Any, Port); + this.logicOfServer = new LogicOfServer(baseDirectory); + } - public ServerClass(string baseDirectory) - { - this.listener = new TcpListener(IPAddress.Any, Port); - this.logicOfServer = new LogicOfServer(baseDirectory); - } + /// + /// Starts listening for incoming connections and creates tasks for processing each client. + /// + /// A task representing an asynchronous server operation. + public async Task Start() + { + this.listener.Start(); + Console.WriteLine($"Listening on port {Port}..."); - public async Task Start() + while (true) { - this.listener.Start(); - Console.WriteLine($"Listening on port {Port}..."); - - while (true) + try { - try - { - TcpClient client = await this.listener.AcceptTcpClientAsync(); - Task clientTask = Task.Run(() => this.ClientHandler(client)); - } - catch (Exception exception) - { - Console.WriteLine($"Error accepting client {exception.Message}"); - } + TcpClient client = await this.listener.AcceptTcpClientAsync(); + Task clientTask = Task.Run(() => this.ClientHandler(client)); + } + catch (Exception exception) + { + Console.WriteLine($"Error accepting client {exception.Message}"); } } + } - private async Task ClientHandler(TcpClient client) + private async Task ClientHandler(TcpClient client) + { + Console.WriteLine("Client connected"); + try { - Console.WriteLine("Client connected"); - try + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); { - await using NetworkStream stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + string? line = string.Empty; + while ((line = await reader.ReadLineAsync()) != null) { - string? line = string.Empty; - while ((line = await reader.ReadLineAsync()) != null) + Console.WriteLine($"Request: {line}"); + string[] parts = line.Split(' ', 2); + if (parts.Length < 2) { - Console.WriteLine($"Request: {line}"); - string[] parts = line.Split(' ', 2); - if (parts.Length < 2) - { - continue; - } - - string command = parts[0]; - string path = parts[1]; - - if (!this.logicOfServer.IsPathSafe(path, out string? fullPath) || fullPath == null) - { - Console.WriteLine($"access denied {path}"); - switch (command) - { - case "1": - await writer.WriteLineAsync("-1"); - break; - case "2": - await writer.FlushAsync(); - byte[] errorBytes = BitConverter.GetBytes(-1L); - await stream.WriteAsync(errorBytes); - break; - } + continue; + } - continue; - } + string command = parts[0]; + string path = parts[1]; + if (!this.logicOfServer.IsPathSafe(path, out string? fullPath) || fullPath == null) + { + Console.WriteLine($"access denied {path}"); switch (command) { case "1": - await this.logicOfServer.List(writer, fullPath); + await writer.WriteLineAsync("-1"); break; case "2": await writer.FlushAsync(); - await this.logicOfServer.Get(stream, fullPath); - break; - default: - await writer.WriteLineAsync("-1"); + byte[] errorBytes = BitConverter.GetBytes(-1L); + await stream.WriteAsync(errorBytes); break; } - await writer.FlushAsync(); + continue; + } + + switch (command) + { + case "1": + await this.logicOfServer.List(writer, fullPath); + break; + case "2": + await writer.FlushAsync(); + await this.logicOfServer.Get(stream, fullPath); + break; + default: + await writer.WriteLineAsync("-1"); + break; } + + await writer.FlushAsync(); } } - catch (Exception exception) - { - Console.WriteLine($"Error handling client {exception.Message}"); - } - finally - { - client.Close(); - Console.WriteLine("Client disconnected"); - } + } + catch (Exception exception) + { + Console.WriteLine($"Error handling client {exception.Message}"); + } + finally + { + client.Close(); + Console.WriteLine("Client disconnected"); } } } \ No newline at end of file diff --git a/SimpleFTP/Server/SimpleFTP.Server.csproj b/SimpleFTP/Server/SimpleFTP.Server.csproj index 90dd746..d764014 100644 --- a/SimpleFTP/Server/SimpleFTP.Server.csproj +++ b/SimpleFTP/Server/SimpleFTP.Server.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/SimpleFTP/Server/stylecop.json b/SimpleFTP/Server/stylecop.json new file mode 100644 index 0000000..c7ed419 --- /dev/null +++ b/SimpleFTP/Server/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "Kalinin Andrew" + } + } +} diff --git a/SimpleFTP/SimpleFTP.Tests/.editorconfig b/SimpleFTP/SimpleFTP.Tests/.editorconfig new file mode 100644 index 0000000..e44bd88 --- /dev/null +++ b/SimpleFTP/SimpleFTP.Tests/.editorconfig @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleFTP.Tests +{ + class _ + { + } +} + +[*.cs] +dotnet_diagnostic.SA0001.severity = none + +[*.cs] +dotnet_diagnostic.SA1600.severity = none + +[*.cs] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Стили именования + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +[*.vb] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_начинается_с_i.severity = suggestion +dotnet_naming_rule.interface_should_be_начинается_с_i.symbols = interface +dotnet_naming_rule.interface_should_be_начинается_с_i.style = начинается_с_i + +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.symbols = типы +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.symbols = не_являющиеся_полем_члены +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.типы.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.типы.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.типы.required_modifiers = + +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_kinds = property, event, method +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.не_являющиеся_полем_члены.required_modifiers = + +# Стили именования + +dotnet_naming_style.начинается_с_i.required_prefix = I +dotnet_naming_style.начинается_с_i.required_suffix = +dotnet_naming_style.начинается_с_i.word_separator = +dotnet_naming_style.начинается_с_i.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 \ No newline at end of file diff --git a/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs b/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs index 1a325c5..151505b 100644 --- a/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs +++ b/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs @@ -1,4 +1,8 @@ -namespace SimpleFTP.Tests; +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +namespace SimpleFTP.Tests; public class ClientParsingTests { diff --git a/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj b/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj index 2d80f56..8363e19 100644 --- a/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj +++ b/SimpleFTP/SimpleFTP.Tests/SimpleFTP.Tests.csproj @@ -14,15 +14,23 @@ - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/SimpleFTP/SimpleFTP.Tests/stylecop.json b/SimpleFTP/SimpleFTP.Tests/stylecop.json new file mode 100644 index 0000000..c7ed419 --- /dev/null +++ b/SimpleFTP/SimpleFTP.Tests/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "Kalinin Andrew" + } + } +} From 4724a02cc1f18e3f12daa7cfd7a2ae71584b0bdc Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sat, 1 Nov 2025 03:10:56 +0300 Subject: [PATCH 07/13] add: method to stop the server --- SimpleFTP/Server/Server.cs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index aa8bf6c..fad303e 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -16,6 +16,8 @@ public class Server private const int Port = 8888; private readonly TcpListener listener; private readonly LogicOfServer logicOfServer; + private CancellationTokenSource cts; + private bool isWorking; /// /// Initializes a new instance of the class. @@ -34,22 +36,51 @@ public Server(string baseDirectory) public async Task Start() { this.listener.Start(); + this.isWorking = true; Console.WriteLine($"Listening on port {Port}..."); - while (true) + while (this.isWorking) { try { - TcpClient client = await this.listener.AcceptTcpClientAsync(); + TcpClient client = await this.listener.AcceptTcpClientAsync(this.cts.Token); Task clientTask = Task.Run(() => this.ClientHandler(client)); } + catch (OperationCanceledException) + { + break; + } catch (Exception exception) { + if (!this.isWorking) + { + break; + } Console.WriteLine($"Error accepting client {exception.Message}"); } } } + /// + /// Initiates the server shutdown process by closing listener and canceling all pending operations. + /// + public void Stop() + { + this.isWorking = false; + this.cts?.Cancel(); + this.listener?.Stop(); + } + + /// + /// Frees up resources. + /// + public void Dispose() + { + this.Stop(); + this.listener?.Dispose(); + this.cts?.Dispose(); + } + private async Task ClientHandler(TcpClient client) { Console.WriteLine("Client connected"); From 141d4fe0f1069268ea972bb2406bd3ff15644666 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sat, 1 Nov 2025 03:49:32 +0300 Subject: [PATCH 08/13] style: changes in accordance with stylecop --- SimpleFTP/Server/Server.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index fad303e..0fffec0 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -27,6 +27,7 @@ public Server(string baseDirectory) { this.listener = new TcpListener(IPAddress.Any, Port); this.logicOfServer = new LogicOfServer(baseDirectory); + this.cts = new CancellationTokenSource(); } /// @@ -56,6 +57,7 @@ public async Task Start() { break; } + Console.WriteLine($"Error accepting client {exception.Message}"); } } From e1618c2ebe4ebbe545d0d2289f4af788bb94fddd Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 5 Nov 2025 03:12:33 +0300 Subject: [PATCH 09/13] test: add tests for command "list" fix: add FlushAsync() to ensure data is sent immediately --- SimpleFTP/Server/LogicOfServer.cs | 2 + .../SimpleFTP.Tests/ClientParsingTests.cs | 14 --- SimpleFTP/SimpleFTP.Tests/ServerTests.cs | 119 ++++++++++++++++++ 3 files changed, 121 insertions(+), 14 deletions(-) delete mode 100644 SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs create mode 100644 SimpleFTP/SimpleFTP.Tests/ServerTests.cs diff --git a/SimpleFTP/Server/LogicOfServer.cs b/SimpleFTP/Server/LogicOfServer.cs index cb29662..75dcfcf 100644 --- a/SimpleFTP/Server/LogicOfServer.cs +++ b/SimpleFTP/Server/LogicOfServer.cs @@ -86,6 +86,7 @@ public async Task Get(Stream stream, string filePath) { byte[] errorInBytes = BitConverter.GetBytes(-1L); await stream.WriteAsync(errorInBytes); + await stream.FlushAsync(); return; } @@ -94,6 +95,7 @@ public async Task Get(Stream stream, string filePath) long fileSize = file.Length; byte[] sizeBytes = BitConverter.GetBytes(fileSize); await stream.WriteAsync(sizeBytes); + await stream.FlushAsync(); await using (FileStream fileStream = file.OpenRead()) { diff --git a/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs b/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs deleted file mode 100644 index 151505b..0000000 --- a/SimpleFTP/SimpleFTP.Tests/ClientParsingTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright (c) Kalinin Andrew. All rights reserved. -// - -namespace SimpleFTP.Tests; - -public class ClientParsingTests -{ - [Test] - public void List_EmptyDirectory_ShouldBehaveProperly() - { - Assert.Pass(); - } -} \ No newline at end of file diff --git a/SimpleFTP/SimpleFTP.Tests/ServerTests.cs b/SimpleFTP/SimpleFTP.Tests/ServerTests.cs new file mode 100644 index 0000000..79cca82 --- /dev/null +++ b/SimpleFTP/SimpleFTP.Tests/ServerTests.cs @@ -0,0 +1,119 @@ +// +// Copyright (c) Kalinin Andrew. All rights reserved. +// + +namespace SimpleFTP.Tests; + +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using SimpleFTP.Server; +using static System.Net.Mime.MediaTypeNames; + +public class ServerTests +{ + private const string IP = "127.0.0.1"; + private const int PORT = 8888; + private const string TestTextData = "Abcd,Efg"; + private readonly string baseDirectory = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestServerBase"); + private readonly int[] testIntData = new int[] { 59, -1, 10000, 52 }; + private Server? server; + private Task? serverTask; + private byte[] testBinaryData = Array.Empty(); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + this.testBinaryData = this.IntToBytes(this.testIntData); + + if (Directory.Exists(this.baseDirectory)) + { + Directory.Delete(this.baseDirectory, true); + } + + Directory.CreateDirectory(this.baseDirectory); + Directory.CreateDirectory(Path.Combine(this.baseDirectory, "Folder")); + Directory.CreateDirectory(Path.Combine(this.baseDirectory, "EmptyFolder")); + + File.WriteAllText(Path.Combine(this.baseDirectory, "file.txt"), TestTextData); + File.WriteAllBytes(Path.Combine(this.baseDirectory, "file.bin"), this.testBinaryData); + File.WriteAllText(Path.Combine(this.baseDirectory, "Folder", "FileInFolder.txt"), "qwerty"); + + this.server = new Server(this.baseDirectory); + this.serverTask = this.server.Start(); + + Thread.Sleep(300); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + this.server?.Stop(); + try + { + this.serverTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception exception) + { + Console.WriteLine($"Error during server task teardown: {exception.Message}"); + } + + if (Directory.Exists(this.baseDirectory)) + { + Directory.Delete(this.baseDirectory, true); + } + } + + [Test] + public async Task List_ValidDirectory_ShouldReturnCorrectList() + { + var response = await this.PerformList("."); + + Assert.That(response, Is.Not.Null); + Assert.That(response, Does.StartWith("4")); + Assert.That(response, Contains.Substring(" file.txt false")); + Assert.That(response, Contains.Substring(" file.bin false")); + Assert.That(response, Contains.Substring(" Folder true")); + Assert.That(response, Contains.Substring(" EmptyFolder true")); + } + + [Test] + public async Task List_EmptyDirectory_ShouldReturnCorrectList() + { + var response = await this.PerformList("EmptyFolder"); + Assert.That(response, Is.EqualTo("0")); + } + + [Test] + public async Task List_NonExistentDirectory_ShouldReturnException() + { + var response = await this.PerformList("kmnds"); + Assert.That(response, Is.EqualTo("-1")); + } + + private async Task PerformList(string path) + { + using var client = new TcpClient(); + await client.ConnectAsync(IP, PORT); + await using var stream = client.GetStream(); + await using var writer = new StreamWriter(stream) { AutoFlush = true }; + using var reader = new StreamReader(stream); + + await writer.WriteLineAsync($"1 {path}"); + return await reader.ReadLineAsync(); + } + + private byte[] IntToBytes(int[] data) + { + using (var memoryStream = new MemoryStream()) + { + foreach (int num in this.testIntData) + { + byte[] bytes = BitConverter.GetBytes(num); + memoryStream.Write(bytes, 0, bytes.Length); + } + + return memoryStream.ToArray(); + } + } +} \ No newline at end of file From d2f486906fae3a7cbea866736590ecf1b58360c6 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 5 Nov 2025 03:29:11 +0300 Subject: [PATCH 10/13] fix: add FlushAsync() after the file has been copied to the network stream. --- SimpleFTP/Server/LogicOfServer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SimpleFTP/Server/LogicOfServer.cs b/SimpleFTP/Server/LogicOfServer.cs index 75dcfcf..9e92107 100644 --- a/SimpleFTP/Server/LogicOfServer.cs +++ b/SimpleFTP/Server/LogicOfServer.cs @@ -102,6 +102,8 @@ public async Task Get(Stream stream, string filePath) await fileStream.CopyToAsync(stream); } + await stream.FlushAsync(); + Console.WriteLine($"Sent file ({filePath}) ({fileSize} bytes)"); } catch (Exception exception) From e46dfc0b72c04b5a68e9a65f0bafecd4011b378b Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 5 Nov 2025 03:38:34 +0300 Subject: [PATCH 11/13] fix: ensure stream flush after all server responses --- SimpleFTP/Server/Server.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index 0fffec0..0ed8053 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -112,11 +112,13 @@ private async Task ClientHandler(TcpClient client) { case "1": await writer.WriteLineAsync("-1"); + await writer.FlushAsync(); break; case "2": await writer.FlushAsync(); byte[] errorBytes = BitConverter.GetBytes(-1L); await stream.WriteAsync(errorBytes); + await stream.FlushAsync(); break; } From ae803ab38e9f230aa70201210c68e9cb0a506688 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 5 Nov 2025 05:07:32 +0300 Subject: [PATCH 12/13] fix: server client handler stream buffer conflict --- SimpleFTP/Server/Server.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/SimpleFTP/Server/Server.cs b/SimpleFTP/Server/Server.cs index 0ed8053..98f86cd 100644 --- a/SimpleFTP/Server/Server.cs +++ b/SimpleFTP/Server/Server.cs @@ -89,12 +89,18 @@ private async Task ClientHandler(TcpClient client) try { await using NetworkStream stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); { string? line = string.Empty; - while ((line = await reader.ReadLineAsync()) != null) + while (true) { + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + line = await reader.ReadLineAsync(); + if (line == null) + { + break; + } + Console.WriteLine($"Request: {line}"); string[] parts = line.Split(' ', 2); if (parts.Length < 2) @@ -115,7 +121,6 @@ private async Task ClientHandler(TcpClient client) await writer.FlushAsync(); break; case "2": - await writer.FlushAsync(); byte[] errorBytes = BitConverter.GetBytes(-1L); await stream.WriteAsync(errorBytes); await stream.FlushAsync(); @@ -129,17 +134,16 @@ private async Task ClientHandler(TcpClient client) { case "1": await this.logicOfServer.List(writer, fullPath); + await writer.FlushAsync(); break; case "2": - await writer.FlushAsync(); await this.logicOfServer.Get(stream, fullPath); break; default: await writer.WriteLineAsync("-1"); + await writer.FlushAsync(); break; } - - await writer.FlushAsync(); } } } From 5dbf9f0adf698b4feb275742193d6b0fc5fd9103 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 5 Nov 2025 05:31:28 +0300 Subject: [PATCH 13/13] test: add a tests for "get" --- SimpleFTP/SimpleFTP.Tests/ServerTests.cs | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/SimpleFTP/SimpleFTP.Tests/ServerTests.cs b/SimpleFTP/SimpleFTP.Tests/ServerTests.cs index 79cca82..4a290e9 100644 --- a/SimpleFTP/SimpleFTP.Tests/ServerTests.cs +++ b/SimpleFTP/SimpleFTP.Tests/ServerTests.cs @@ -91,6 +91,28 @@ public async Task List_NonExistentDirectory_ShouldReturnException() Assert.That(response, Is.EqualTo("-1")); } + [Test] + public async Task Get_ValidTextFile_ShouldReturnCorrectContent() + { + var (size, data) = await this.PerformGet("file.txt"); + string received = Encoding.UTF8.GetString(data); + Assert.That(received, Is.EqualTo(TestTextData)); + } + + [Test] + public async Task Get_ValidBinaryFile_ShouldReturnCorrectContent() + { + var (size, data) = await this.PerformGet("file.bin"); + Assert.That(data, Is.EqualTo(this.IntToBytes(this.testIntData))); + } + + [Test] + public async Task Get_FileInSubFolder_ShouldReturnCorrectContent() + { + var (size, data) = await this.PerformGet("Folder/FileInFolder.txt"); + Assert.That(data, Is.EqualTo("qwerty")); + } + private async Task PerformList(string path) { using var client = new TcpClient(); @@ -103,6 +125,48 @@ public async Task List_NonExistentDirectory_ShouldReturnException() return await reader.ReadLineAsync(); } + private async Task<(long Size, byte[] Data)> PerformGet(string path) + { + using var client = new TcpClient(); + await client.ConnectAsync(IP, PORT); + await using var stream = client.GetStream(); + + string command = $"2 {path}\n"; + byte[] commandBytes = Encoding.UTF8.GetBytes(command); + await stream.WriteAsync(commandBytes); + await stream.FlushAsync(); + + await Task.Delay(100); + + byte[] sizeBuffer = new byte[8]; + + await stream.ReadExactlyAsync(sizeBuffer, 0, 8); + long fileSize = BitConverter.ToInt64(sizeBuffer, 0); + + if (fileSize == -1) + { + return (-1L, Array.Empty()); + } + + using var memoryStream = new MemoryStream(); + byte[] buffer = new byte[81920]; + long totalBytesRead = 0; + while (totalBytesRead < fileSize) + { + int bytesToRead = (int)Math.Min(fileSize - totalBytesRead, buffer.Length); + int bytesRead = await stream.ReadAsync(buffer, 0, bytesToRead); + if (bytesRead == 0) + { + throw new EndOfStreamException("Connection closed"); + } + + await memoryStream.WriteAsync(buffer, 0, bytesRead); + totalBytesRead += bytesRead; + } + + return (fileSize, memoryStream.ToArray()); + } + private byte[] IntToBytes(int[] data) { using (var memoryStream = new MemoryStream())