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