diff --git a/HW4/HW4.sln b/HW4/HW4.sln new file mode 100644 index 0000000..3db7374 --- /dev/null +++ b/HW4/HW4.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyFTP", "MyFTP\MyFTP.csproj", "{15BAB866-F9EC-4581-A2DA-D9436094B887}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyFTP.client", "MyFTP.client\MyFTP.client.csproj", "{9A29907E-A801-4F31-8176-2DD0F1A9F475}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyFTP.Tests", "MyFTP.Tests\MyFTP.Tests.csproj", "{75720F9D-8B5C-4EA4-ABB9-FEFD37969DBF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {15BAB866-F9EC-4581-A2DA-D9436094B887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15BAB866-F9EC-4581-A2DA-D9436094B887}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15BAB866-F9EC-4581-A2DA-D9436094B887}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15BAB866-F9EC-4581-A2DA-D9436094B887}.Release|Any CPU.Build.0 = Release|Any CPU + {9A29907E-A801-4F31-8176-2DD0F1A9F475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A29907E-A801-4F31-8176-2DD0F1A9F475}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A29907E-A801-4F31-8176-2DD0F1A9F475}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A29907E-A801-4F31-8176-2DD0F1A9F475}.Release|Any CPU.Build.0 = Release|Any CPU + {75720F9D-8B5C-4EA4-ABB9-FEFD37969DBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75720F9D-8B5C-4EA4-ABB9-FEFD37969DBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75720F9D-8B5C-4EA4-ABB9-FEFD37969DBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75720F9D-8B5C-4EA4-ABB9-FEFD37969DBF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/HW4/MyFTP.Tests/MyFTP.Tests.csproj b/HW4/MyFTP.Tests/MyFTP.Tests.csproj new file mode 100644 index 0000000..de208a8 --- /dev/null +++ b/HW4/MyFTP.Tests/MyFTP.Tests.csproj @@ -0,0 +1,49 @@ + + + + net9.0 + enable + enable + true + false + true + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/HW4/MyFTP.Tests/MyFtpIntegrationTests.cs b/HW4/MyFTP.Tests/MyFtpIntegrationTests.cs new file mode 100644 index 0000000..29d5034 --- /dev/null +++ b/HW4/MyFTP.Tests/MyFtpIntegrationTests.cs @@ -0,0 +1,227 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyFTP.Tests; + +using System.Diagnostics; +using System.Security.Cryptography; + +/// +/// End-to-end integration tests for the MyFTP. +/// +public class MyFtpIntegrationTests +{ + private const int Port = 12345; + private const string ClientExe = @"..\..\..\..\MyFTP.client\bin\Debug\net9.0\MyFTP.client.exe"; + private const string ServerExe = @"..\..\..\..\MyFTP\bin\Debug\net9.0\MyFTP.exe"; + private const string ServerRoot = @"..\..\..\..\MyFTP"; + + private Process? server; + + /// + /// Starts the server. + /// + [OneTimeSetUp] + public void StartServer() + { + KillAllProcesses(); + + var serverPath = Path.GetFullPath(ServerExe); + if (!File.Exists(serverPath)) + { + Assert.Inconclusive($"\nServer not found: {serverPath}"); + } + + this.server = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = serverPath, + Arguments = Port.ToString(), + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetFullPath(ServerRoot), + }, + }; + + this.server.Start(); + Thread.Sleep(3000); + } + + /// + /// Stops the server. + /// + [OneTimeTearDown] + public void StopServer() + { + if (this.server is { HasExited: false }) + { + try + { + this.server.Kill(); + } + catch + { + // ignored + } + } + + this.server?.Dispose(); + KillAllProcesses(); + } + + /// + /// Clears the folder. + /// + [SetUp] + public void Cleanup() + { + var dir = TestContext.CurrentContext.TestDirectory; + foreach (var file in new[] { "file.txt", "pin.jpg", "123.docx", "111.txt" }) + { + var path = Path.Combine(dir, file); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + /// + /// Checking the List command for the directory ./Test. + /// + [Test] + public void IntegrationTest_ReturnsCorrectFileList() + { + var output = RunClient("1 ./Test"); + + var line = output.Split('\n') + .Select(l => l.Trim()) + .FirstOrDefault(l => l.Contains("./Test/pin.jpg") && l.Contains("./Test/dir")); + + Assert.That(line, Is.Not.Null, $"List didn't work! Full output:\n{output}"); + StringAssert.Contains("./Test/pin.jpg false", line); + StringAssert.Contains("./Test/dir true", line); + } + + /// + /// Checking the download of a .txt file. + /// + [Test] + public void IntegrationTest_ExistingTextFile_DownloadsSuccessfully() + { + RunClient("2 ./Test/file.txt"); + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "file.txt"); + Assert.That(File.Exists(path), Is.True, "file.txt did not download!"); + } + + /// + /// Checking the download of a .jpg file. + /// + [Test] + public void IntegrationTest_ExistingJpgFile_DownloadsSuccessfully() + { + var original = Path.Combine(ServerRoot, "Test", "pin.jpg"); + var originalHash = GetSha256(original); + + RunClient("2 ./Test/pin.jpg"); + + var downloaded = Path.Combine(TestContext.CurrentContext.TestDirectory, "pin.jpg"); + Assert.Multiple(() => + { + Assert.That(File.Exists(downloaded), Is.True, "pin.jpg did not download!"); + Assert.That(GetSha256(downloaded), Is.EqualTo(originalHash)); + }); + } + + /// + /// Checking the download of a .docx file. + /// + [Test] + public void IntegrationTest_ExistingDocxFile_DownloadsSuccessfully() + { + var original = Path.Combine(ServerRoot, "Test", "123.docx"); + var originalHash = GetSha256(original); + + RunClient("2 ./Test/123.docx"); + + var downloaded = Path.Combine(TestContext.CurrentContext.TestDirectory, "123.docx"); + Assert.Multiple(() => + { + Assert.That(File.Exists(downloaded), Is.True, "123.docx did not download!"); + Assert.That(GetSha256(downloaded), Is.EqualTo(originalHash)); + }); + } + + /// + /// Checking the behavior when requesting a non-existent file. + /// + [Test] + public void IntegrationTest_NonExistentFile_ReturnsMinusOne() + { + var output = RunClient("2 ./Test/nonexistent.txt"); + StringAssert.Contains("-1", output); + } + + private static string RunClient(string command) + { + var clientPath = Path.GetFullPath(ClientExe); + var psi = new ProcessStartInfo + { + FileName = clientPath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = TestContext.CurrentContext.TestDirectory, + }; + + using var client = Process.Start(psi)!; + client.StandardInput.WriteLine(command); + client.StandardInput.WriteLine("exit"); + client.StandardInput.Flush(); + + var output = client.StandardOutput.ReadToEnd(); + var error = client.StandardError.ReadToEnd(); + + client.WaitForExit(20000); + if (!client.HasExited) + { + client.Kill(); + } + + if (client.ExitCode != 0) + { + Assert.Fail($"The client has fallen!\nError: {error}\nOutput: {output}"); + } + + return output; + } + + private static string GetSha256(string path) + { + using var sha256 = SHA256.Create(); + using var stream = File.OpenRead(path); + return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static void KillAllProcesses() + { + foreach (var p in Process.GetProcesses()) + { + if (p.ProcessName.Contains("MyFTP")) + { + try + { + p.Kill(); + } + catch + { + // ignored + } + } + } + } +} \ No newline at end of file diff --git a/HW4/MyFTP.Tests/stylecop.json b/HW4/MyFTP.Tests/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW4/MyFTP.Tests/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "khusainovilas", + "copyrightText": "Copyright (c) {companyName}. All rights reserved." + } + } +} \ No newline at end of file diff --git a/HW4/MyFTP.client/MyFTP.client.csproj b/HW4/MyFTP.client/MyFTP.client.csproj new file mode 100644 index 0000000..562779e --- /dev/null +++ b/HW4/MyFTP.client/MyFTP.client.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HW4/MyFTP.client/Program.cs b/HW4/MyFTP.client/Program.cs new file mode 100644 index 0000000..1b17c24 --- /dev/null +++ b/HW4/MyFTP.client/Program.cs @@ -0,0 +1,194 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +using System.Net.Sockets; +using System.Text; + +const string host = "127.0.0.1"; +const int port = 12345; + +TcpClient? client = null; + +try +{ + client = new TcpClient(); + await client.ConnectAsync(host, port); + var stream = client.GetStream(); + + while (true) + { + Console.Write("\n> "); + var input = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(input)) + { + continue; + } + + if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + var parts = input.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 2 || (parts[0] != "1" && parts[0] != "2")) + { + Console.WriteLine(" 1 - list | 2 – get | exit "); + continue; + } + + await stream.WriteAsync(Encoding.ASCII.GetBytes(input + "\n")); + + if (parts[0] == "1") + { + var line = await ReadLineAsync(stream); + Console.WriteLine(line ?? "-1"); + } + else + { + var (size, firstByte) = await ReadSizeAndFirstByteAsync(stream); + + if (size == -1) + { + Console.WriteLine("-1"); + continue; + } + + if (size < 0) + { + throw new FormatException($"Invalid file size received: {size}"); + } + + Console.Write(size); + Console.Write(' '); + + var fileName = Path.GetFileName(parts[1]); + if (string.IsNullOrEmpty(fileName)) + { + fileName = "file"; + } + + await using var fs = new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + + var remaining = size; + const int previewBytes = 256; + var previewLeft = Math.Min(previewBytes, size); + var buffer = new byte[81920]; + + if (firstByte.HasValue) + { + if (previewLeft > 0) + { + Console.OpenStandardOutput().WriteByte(firstByte.Value); + previewLeft--; + } + + await fs.WriteAsync(new[] { firstByte.Value }); + remaining--; + } + + while (remaining > 0) + { + var toRead = (int)Math.Min(buffer.Length, remaining); + var read = await stream.ReadAsync(buffer.AsMemory(0, toRead)); + if (read <= 0) + { + throw new IOException("Connection lost"); + } + + if (previewLeft > 0) + { + var previewThisTime = (int)Math.Min(read, previewLeft); + Console.OpenStandardOutput().Write(buffer, 0, previewThisTime); + previewLeft -= previewThisTime; + } + + await fs.WriteAsync(buffer.AsMemory(0, read)); + remaining -= read; + } + + if (size > previewBytes) + { + Console.WriteLine($"\n... [truncated, full file saved as {fileName}]"); + } + else + { + Console.WriteLine(); + } + } + } +} +catch (SocketException) when (client is { Connected: false }) +{ + Console.Error.WriteLine("Error: Could not connect to server"); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error: {ex.GetType().Name}: {ex.Message}"); +} +finally +{ + client?.Dispose(); +} + +return; + +static async Task ReadLineAsync(NetworkStream s) +{ + var sb = new StringBuilder(); + var b = new byte[1]; + + while (await s.ReadAsync(b) > 0) + { + if (b[0] == '\n') + { + break; + } + + if (b[0] != '\r') + { + sb.Append((char)b[0]); + } + } + + return sb.Length == 0 ? null : sb.ToString(); +} + +static async Task<(long Size, byte? FirstByte)> ReadSizeAndFirstByteAsync(NetworkStream s) +{ + var sb = new StringBuilder(); + var b = new byte[1]; + + while (await s.ReadAsync(b) > 0) + { + var ch = (char)b[0]; + + if (ch is >= '0' and <= '9') + { + sb.Append(ch); + } + else if (ch == '-' && sb.Length == 0) + { + sb.Append(ch); + } + else + { + var sizeStr = sb.ToString(); + + if (sizeStr == "-1") + { + return (-1, null); + } + + if (!long.TryParse(sizeStr, out var size) || size < 0) + { + throw new FormatException($"Invalid size format from server: '{sizeStr}'"); + } + + return (size, b[0]); + } + } + + throw new IOException("Unexpected end of stream while reading file size"); +} \ No newline at end of file diff --git a/HW4/MyFTP.client/stylecop.json b/HW4/MyFTP.client/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW4/MyFTP.client/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "khusainovilas", + "copyrightText": "Copyright (c) {companyName}. All rights reserved." + } + } +} \ No newline at end of file diff --git a/HW4/MyFTP/MyFTP.csproj b/HW4/MyFTP/MyFTP.csproj new file mode 100644 index 0000000..562779e --- /dev/null +++ b/HW4/MyFTP/MyFTP.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HW4/MyFTP/Program.cs b/HW4/MyFTP/Program.cs new file mode 100644 index 0000000..8913eb5 --- /dev/null +++ b/HW4/MyFTP/Program.cs @@ -0,0 +1,120 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +using System.Net; +using System.Net.Sockets; +using System.Text; + +const int port = 12345; +var root = Environment.CurrentDirectory; + +var listener = new TcpListener(IPAddress.Any, port); +listener.Start(); + +Console.WriteLine($"Launched on the port {port}"); +Console.WriteLine($"The root directory: {root}"); + +while (true) +{ + var client = await listener.AcceptTcpClientAsync(); + _ = Task.Run(() => HandleClientAsync(client)); +} + +async Task HandleClientAsync(TcpClient client) +{ + Console.WriteLine("Successfully connected!"); + + try + { + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + + while (true) + { + var line = await reader.ReadLineAsync(); + if (line is null) + { + break; + } + + await ProcessRequestAsync(stream, line); + } + } + catch (Exception ex) + { + Console.WriteLine($"Client error: {ex.Message}"); + } + finally + { + client.Dispose(); + Console.WriteLine("The client is disconnected"); + } +} + +async Task ProcessRequestAsync(NetworkStream stream, string request) +{ + var parts = request.Split(' ', 2); + if (parts.Length < 2 || !int.TryParse(parts[0], out var cmd)) + { + return; + } + + var path = parts[1]; + var fullPath = Path.GetFullPath(path); + + if (Path.GetRelativePath(root, fullPath).StartsWith("..", StringComparison.Ordinal)) + { + await stream.WriteAsync("-1\n"u8.ToArray()); + return; + } + + if (cmd == 1) + { + if (!Directory.Exists(fullPath)) + { + await stream.WriteAsync("-1\n"u8.ToArray()); + return; + } + + var entries = new DirectoryInfo(fullPath) + .GetFileSystemInfos() + .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var sb = new StringBuilder(); + sb.Append(entries.Length); + + foreach (var e in entries) + { + var display = ToDisplayPath(e.FullName); + var isDir = e is DirectoryInfo; + sb.Append(' ').Append(display).Append(' ').Append(isDir ? "true" : "false"); + } + + sb.Append('\n'); + + await stream.WriteAsync(Encoding.ASCII.GetBytes(sb.ToString())); + } + else if (cmd == 2) + { + if (!File.Exists(fullPath)) + { + await stream.WriteAsync("-1\n"u8.ToArray()); + return; + } + + var size = new FileInfo(fullPath).Length; + + await stream.WriteAsync(Encoding.ASCII.GetBytes(size.ToString())); + + await using var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true); + await fs.CopyToAsync(stream); + } +} + +string ToDisplayPath(string fullPath) +{ + var rel = Path.GetRelativePath(root, fullPath).Replace('\\', '/'); + return rel == "." ? "." : rel.StartsWith("./") ? rel : "./" + rel; +} \ No newline at end of file diff --git a/HW4/MyFTP/Test/111.txt b/HW4/MyFTP/Test/111.txt new file mode 100644 index 0000000..9825479 --- /dev/null +++ b/HW4/MyFTP/Test/111.txt @@ -0,0 +1 @@ +22222222222222222222222222 \ No newline at end of file diff --git a/HW4/MyFTP/Test/123.docx b/HW4/MyFTP/Test/123.docx new file mode 100644 index 0000000..b849455 Binary files /dev/null and b/HW4/MyFTP/Test/123.docx differ diff --git a/HW4/MyFTP/Test/dir/.gitkeep b/HW4/MyFTP/Test/dir/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/HW4/MyFTP/Test/file.txt b/HW4/MyFTP/Test/file.txt new file mode 100644 index 0000000..05a682b --- /dev/null +++ b/HW4/MyFTP/Test/file.txt @@ -0,0 +1 @@ +Hello! \ No newline at end of file diff --git a/HW4/MyFTP/Test/pin.jpg b/HW4/MyFTP/Test/pin.jpg new file mode 100644 index 0000000..9ac08c7 Binary files /dev/null and b/HW4/MyFTP/Test/pin.jpg differ diff --git a/HW4/MyFTP/stylecop.json b/HW4/MyFTP/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW4/MyFTP/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "khusainovilas", + "copyrightText": "Copyright (c) {companyName}. All rights reserved." + } + } +} \ No newline at end of file