From ee88cd8f6c32114b2a975149f88be2b10591509e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:08:08 +0000 Subject: [PATCH] Fix security vulnerabilities and modernize the transfer API Replace the 0.1.x wire protocol, which let any network client write files anywhere on the server and crash the server process. Security fixes: - Path traversal: the server joined SaveTo with the client-supplied file name, so "..\..\x" or an absolute path wrote outside the save folder. Names are now validated (no separators, drive letters, "..", control characters, ADS or device names) and must resolve inside SaveDirectory. - Remote crash: malformed packets, duplicate names or early disconnects threw on a background thread and killed the process; EOF also caused infinite 100%-CPU loops. All connection errors are now contained. - Unbounded allocation from untrusted length fields. - Optional access-token auth (HMAC-SHA256 challenge, token never sent), optional TLS, and a SHA-256 integrity check on every transfer. - Idle timeouts, a concurrent-connection limit, an optional max file size and a free-space check. Uploads go to a hidden temp file that is deleted on failure; existing files are never overwritten. Modernization: - Async API: EftClient.SendFileAsync/SendAsync with IProgress and cancellation; EftServer.Start/StopAsync with FileReceived and TransferFailed events. The 0.1.x API is kept as [Obsolete] wrappers. - Remove the WarningsNotAsErrors list; the library builds warning-free. - Convert the WinForms samples to SDK-style net10.0-windows projects (they referenced files deleted in the previous commit) and add token and progress UI. - Add an xunit test project (72 tests on net8.0 and net10.0), a GitHub Actions CI workflow, Dependabot config, SECURITY.md and a new README. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P2FYFNHLYir1f4N9VJvgsn --- .github/dependabot.yml | 14 + .github/workflows/ci.yml | 51 ++ .../EasyFileTransfer.Tests.csproj | 26 + .../FileNameValidatorTests.cs | 51 ++ EasyFileTransfer.Tests/RawClient.cs | 67 +++ EasyFileTransfer.Tests/SecurityTests.cs | 307 ++++++++++ EasyFileTransfer.Tests/TestServer.cs | 64 ++ EasyFileTransfer.Tests/TransferTests.cs | 173 ++++++ EasyFileTransfer.sln | 6 + EasyFileTransfer/EasyFileTransfer.csproj | 17 +- EasyFileTransfer/EftClient.cs | 337 ++++++++--- EasyFileTransfer/EftClientOptions.cs | 50 ++ EasyFileTransfer/EftException.cs | 53 ++ EasyFileTransfer/EftProgress.cs | 24 + EasyFileTransfer/EftServer.cs | 566 +++++++++++++++--- EasyFileTransfer/EftServerOptions.cs | 93 +++ EasyFileTransfer/EftStatus.cs | 38 ++ EasyFileTransfer/FileReceivedEventArgs.cs | 48 ++ EasyFileTransfer/Internal/Connection.cs | 108 ++++ .../Internal/FileNameValidator.cs | 59 ++ EasyFileTransfer/Internal/Protocol.cs | 110 ++++ EasyFileTransfer/Model/Response.cs | 15 +- README.md | 165 ++++- SECURITY.md | 14 + Sample.Client/Form1.Designer.cs | 51 +- Sample.Client/Form1.cs | 48 +- Sample.Client/Program.cs | 8 +- Sample.Client/Sample.Client.csproj | 98 +-- Sample.Server/Form1.Designer.cs | 49 +- Sample.Server/Form1.cs | 59 +- Sample.Server/Program.cs | 8 +- Sample.Server/Sample.Server.csproj | 98 +-- 32 files changed, 2427 insertions(+), 448 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj create mode 100644 EasyFileTransfer.Tests/FileNameValidatorTests.cs create mode 100644 EasyFileTransfer.Tests/RawClient.cs create mode 100644 EasyFileTransfer.Tests/SecurityTests.cs create mode 100644 EasyFileTransfer.Tests/TestServer.cs create mode 100644 EasyFileTransfer.Tests/TransferTests.cs create mode 100644 EasyFileTransfer/EftClientOptions.cs create mode 100644 EasyFileTransfer/EftException.cs create mode 100644 EasyFileTransfer/EftProgress.cs create mode 100644 EasyFileTransfer/EftServerOptions.cs create mode 100644 EasyFileTransfer/EftStatus.cs create mode 100644 EasyFileTransfer/FileReceivedEventArgs.cs create mode 100644 EasyFileTransfer/Internal/Connection.cs create mode 100644 EasyFileTransfer/Internal/FileNameValidator.cs create mode 100644 EasyFileTransfer/Internal/Protocol.cs create mode 100644 SECURITY.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ecdad1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + test-dependencies: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..890dcf0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore EasyFileTransfer.sln + + - name: Build + run: dotnet build EasyFileTransfer.sln --configuration Release --no-restore + + - name: Test + run: dotnet test EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj --configuration Release --no-build + + - name: Check for vulnerable packages + shell: bash + run: | + dotnet list EasyFileTransfer.sln package --vulnerable --include-transitive 2>&1 | tee vulnerable.txt + ! grep -q -i "has the following vulnerable packages" vulnerable.txt + + - name: Pack + if: matrix.os == 'ubuntu-latest' + run: dotnet pack EasyFileTransfer/EasyFileTransfer.csproj --configuration Release --no-build --output artifacts + + - uses: actions/upload-artifact@v4 + if: matrix.os == 'ubuntu-latest' + with: + name: nuget + path: artifacts/* diff --git a/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj b/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj new file mode 100644 index 0000000..7f87f37 --- /dev/null +++ b/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net10.0 + enable + enable + false + true + true + + + + + + + + + + + + + + + + + diff --git a/EasyFileTransfer.Tests/FileNameValidatorTests.cs b/EasyFileTransfer.Tests/FileNameValidatorTests.cs new file mode 100644 index 0000000..59d495d --- /dev/null +++ b/EasyFileTransfer.Tests/FileNameValidatorTests.cs @@ -0,0 +1,51 @@ +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +public class FileNameValidatorTests +{ + [Theory] + [InlineData("report.pdf")] + [InlineData("archive.tar.gz")] + [InlineData("no-extension")] + [InlineData(".hidden")] + [InlineData("name with spaces.txt")] + [InlineData("\u0641\u0627\u06CC\u0644.txt")] + [InlineData("console.log")] + [InlineData("CONNECT.txt")] + public void Accepts_ordinary_names(string name) => Assert.True(FileNameValidator.IsValid(name)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../evil.txt")] + [InlineData("..\\evil.txt")] + [InlineData("sub/file.txt")] + [InlineData("sub\\file.txt")] + [InlineData("/etc/passwd")] + [InlineData("C:\\Windows\\win.ini")] + [InlineData("C:evil.txt")] + [InlineData("file.txt:stream")] + [InlineData("\\\\server\\share\\x")] + [InlineData("trailing-dot.")] + [InlineData("trailing-space ")] + [InlineData("nul\0byte")] + [InlineData("new\nline")] + [InlineData("wild*card")] + [InlineData("CON")] + [InlineData("con.txt")] + [InlineData("NUL.tar.gz")] + [InlineData("COM1")] + [InlineData("lpt9.log")] + [InlineData("COM\u00B9")] + public void Rejects_dangerous_names(string name) => Assert.False(FileNameValidator.IsValid(name)); + + [Fact] + public void Rejects_names_longer_than_255_characters() + { + Assert.True(FileNameValidator.IsValid(new string('a', 255))); + Assert.False(FileNameValidator.IsValid(new string('a', 256))); + } +} diff --git a/EasyFileTransfer.Tests/RawClient.cs b/EasyFileTransfer.Tests/RawClient.cs new file mode 100644 index 0000000..34cadd8 --- /dev/null +++ b/EasyFileTransfer.Tests/RawClient.cs @@ -0,0 +1,67 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +/// Speaks the wire protocol by hand so tests can send what a real client never would. +internal sealed class RawClient : IDisposable +{ + private readonly TcpClient _tcp; + + public RawClient(int port) + { + _tcp = new TcpClient(); + _tcp.Connect("127.0.0.1", port); + Stream = _tcp.GetStream(); + Stream.ReadTimeout = 15000; + } + + public NetworkStream Stream { get; } + + public byte[] ReadHello() + { + var hello = new byte[Protocol.HelloLength]; + Stream.ReadExactly(hello); + return hello; + } + + public static byte[] Nonce(byte[] hello) => hello.AsSpan(Protocol.MagicLength + 3, Protocol.NonceLength).ToArray(); + + public void SendRequest(byte[] nameBytes, long length, byte[]? proof = null, byte version = Protocol.Version) + { + var request = new byte[Protocol.RequestHeaderLength + nameBytes.Length + 8]; + Protocol.WriteMagic(request, 0); + request[Protocol.MagicLength] = version; + (proof ?? new byte[Protocol.ProofLength]).CopyTo(request, Protocol.MagicLength + 1); + Protocol.WriteUInt16(request, Protocol.RequestHeaderLength - 2, nameBytes.Length); + nameBytes.CopyTo(request, Protocol.RequestHeaderLength); + Protocol.WriteInt64(request, Protocol.RequestHeaderLength + nameBytes.Length, length); + Stream.Write(request); + } + + public void SendRequest(string name, long length, byte[]? proof = null) => + SendRequest(Protocol.StrictUtf8.GetBytes(name), length, proof); + + public void SendContent(byte[] content, byte[]? hash = null) + { + Stream.Write(content); + Stream.Write(hash ?? SHA256.HashData(content)); + } + + /// Reads one status byte, or returns null if the server closed the connection. + public EftStatus? ReadStatus() + { + try + { + int b = Stream.ReadByte(); + return b < 0 ? null : (EftStatus)b; + } + catch (IOException) + { + return null; + } + } + + public void Dispose() => _tcp.Dispose(); +} diff --git a/EasyFileTransfer.Tests/SecurityTests.cs b/EasyFileTransfer.Tests/SecurityTests.cs new file mode 100644 index 0000000..160a372 --- /dev/null +++ b/EasyFileTransfer.Tests/SecurityTests.cs @@ -0,0 +1,307 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +public class SecurityTests +{ + [Theory] + [InlineData("../escaped.txt")] + [InlineData("..\\escaped.txt")] + [InlineData("../../escaped.txt")] + [InlineData("/tmp/escaped.txt")] + [InlineData("C:\\escaped.txt")] + [InlineData("sub/escaped.txt")] + [InlineData("..")] + [InlineData("CON")] + [InlineData("a.txt:ads")] + public void Path_traversal_is_rejected(string maliciousName) + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(maliciousName, 4); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + Assert.Equal(EftStatus.InvalidFileName, Assert.IsType(server.NextFailure().Exception).Status); + Assert.Empty(server.AllFilesUnderRoot()); + } + + [Fact] + public void Invalid_utf8_file_name_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(new byte[] { 0x61, 0xC0, 0xAF, 0x62 }, 4); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + } + + [Theory] + [InlineData(0)] + [InlineData(Protocol.MaxFileNameBytes + 1)] + public void Out_of_range_name_length_is_rejected_before_allocating(int nameLength) + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + var header = new byte[Protocol.RequestHeaderLength]; + Protocol.WriteMagic(header, 0); + header[Protocol.MagicLength] = Protocol.Version; + Protocol.WriteUInt16(header, Protocol.RequestHeaderLength - 2, nameLength); + raw.Stream.Write(header); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + } + + [Fact] + public void Negative_file_length_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest("x.bin", -1); + + Assert.Equal(EftStatus.ProtocolError, raw.ReadStatus()); + } + + [Fact] + public void Unsupported_protocol_version_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(Encoding.UTF8.GetBytes("x.bin"), 1, version: 99); + + Assert.Equal(EftStatus.UnsupportedVersion, raw.ReadStatus()); + } + + [Fact] + public async Task Garbage_input_does_not_crash_the_server() + { + using var server = new TestServer(); + + // The 0.1.x client's first packet, then random noise, then an abrupt disconnect. + foreach (byte[] junk in new[] { "\u0002125" + "8\u0004file.txt", "EFT", "\u0002999abc" }.Select(Encoding.UTF8.GetBytes).Append(RandomNumberGenerator.GetBytes(4096))) + { + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.Stream.Write(junk); + raw.Stream.Write(new byte[Protocol.RequestHeaderLength]); + raw.ReadStatus(); + } + + await server.CreateClient().SendAsync(new MemoryStream("still alive"u8.ToArray()), "ok.txt"); + Assert.Equal(new[] { "ok.txt" }, server.SavedFiles()); + } + + [Fact] + public void Wrong_hash_is_rejected_and_nothing_is_saved() + { + using var server = new TestServer(); + using (var raw = new RawClient(server.Port)) + { + raw.ReadHello(); + raw.SendRequest("tampered.bin", 3); + Assert.Equal(EftStatus.Success, raw.ReadStatus()); + raw.SendContent(new byte[] { 1, 2, 3 }, hash: new byte[32]); + Assert.Equal(EftStatus.IntegrityCheckFailed, raw.ReadStatus()); + } + + server.NextFailure(); + Assert.Empty(Directory.EnumerateFileSystemEntries(server.SaveDirectory)); + } + + [Fact] + public void Truncated_upload_leaves_no_partial_file() + { + using var server = new TestServer(); + using (var raw = new RawClient(server.Port)) + { + raw.ReadHello(); + raw.SendRequest("partial.bin", 1_000_000); + Assert.Equal(EftStatus.Success, raw.ReadStatus()); + raw.Stream.Write(new byte[1000]); + } + + Assert.IsType(server.NextFailure().Exception); + Assert.Empty(Directory.EnumerateFileSystemEntries(server.SaveDirectory)); + } + + [Fact] + public async Task Unwritable_save_directory_reports_server_error() + { + if (OperatingSystem.IsWindows() || Environment.UserName == "root") + { + return; // Permission bits are not enforced here. + } + + using var server = new TestServer(); + File.SetUnixFileMode(server.SaveDirectory, UnixFileMode.UserRead | UnixFileMode.UserExecute); + try + { + var ex = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Equal(EftStatus.ServerError, ex.Status); + } + finally + { + File.SetUnixFileMode(server.SaveDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public async Task Max_file_size_is_enforced() + { + using var server = new TestServer(o => o.MaxFileSize = 100); + var client = server.CreateClient(); + + await client.SendAsync(new MemoryStream(new byte[100]), "fits.bin"); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[101]), "too-big.bin")); + + Assert.Equal(EftStatus.FileTooLarge, ex.Status); + Assert.Equal(new[] { "fits.bin" }, server.SavedFiles()); + } + + [Fact] + public void Absurd_file_size_is_rejected_for_lack_of_space() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest("huge.bin", long.MaxValue); + + Assert.Equal(EftStatus.InsufficientStorage, raw.ReadStatus()); + } + + [Fact] + public async Task Idle_connections_are_dropped() + { + using var server = new TestServer(o => o.IdleTimeout = TimeSpan.FromMilliseconds(300)); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + + Assert.IsType(server.NextFailure().Exception); + Assert.Null(raw.ReadStatus()); + await server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "after.bin"); + } + + [Fact] + public async Task Connection_limit_is_enforced() + { + using var server = new TestServer(o => o.MaxConcurrentConnections = 1); + using var hog = new RawClient(server.Port); + Assert.Equal(EftStatus.Success, (EftStatus)hog.ReadHello()[Protocol.MagicLength + 1]); + + var ex = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Equal(EftStatus.ServerBusy, ex.Status); + + hog.Dispose(); + server.NextFailure(); // busy rejection + server.NextFailure(); // hog disconnected + await server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin"); + } + + [Fact] + public async Task Access_token_is_required_when_configured() + { + using var server = new TestServer(o => o.AccessToken = "correct horse battery staple"); + var content = new MemoryStream(new byte[10]); + + var missing = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(content, "a.bin")); + Assert.Equal(EftStatus.AuthenticationFailed, missing.Status); + + content.Position = 0; + var wrong = await Assert.ThrowsAsync(() => + server.CreateClient(new EftClientOptions { AccessToken = "wrong" }).SendAsync(content, "a.bin")); + Assert.Equal(EftStatus.AuthenticationFailed, wrong.Status); + + content.Position = 0; + await server.CreateClient(new EftClientOptions { AccessToken = "correct horse battery staple" }).SendAsync(content, "a.bin"); + Assert.Equal(new[] { "a.bin" }, server.SavedFiles()); + } + + [Fact] + public void Proof_is_bound_to_the_servers_nonce() + { + using var server = new TestServer(o => o.AccessToken = "secret"); + byte[] replayedProof; + using (var first = new RawClient(server.Port)) + { + replayedProof = Protocol.ComputeProof("secret", RawClient.Nonce(first.ReadHello())); + } + + using var second = new RawClient(server.Port); + second.ReadHello(); + second.SendRequest("replay.bin", 1, replayedProof); + + Assert.Equal(EftStatus.AuthenticationFailed, second.ReadStatus()); + } + + [Fact] + public async Task Tls_transfer_works_with_a_pinned_certificate() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => o.ServerCertificate = certificate); + var client = server.CreateClient(new EftClientOptions + { + UseTls = true, + TlsTargetHost = "localhost", + ServerCertificateValidationCallback = (_, cert, _, _) => + cert != null && cert.GetCertHashString() == certificate.Thumbprint, + }); + + await client.SendAsync(new MemoryStream("encrypted"u8.ToArray()), "secret.txt"); + + Assert.Equal("encrypted", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "secret.txt"))); + } + + [Fact] + public async Task Tls_client_rejects_an_untrusted_certificate_by_default() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => o.ServerCertificate = certificate); + var client = server.CreateClient(new EftClientOptions { UseTls = true, TlsTargetHost = "localhost" }); + + await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Empty(server.SavedFiles()); + } + + [Fact] + public async Task Plaintext_client_cannot_talk_to_a_tls_server() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => + { + o.ServerCertificate = certificate; + o.IdleTimeout = TimeSpan.FromSeconds(2); + }); + var client = server.CreateClient(new EftClientOptions { Timeout = TimeSpan.FromSeconds(5) }); + + await Assert.ThrowsAnyAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Empty(server.SavedFiles()); + } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + request.CertificateExtensions.Add(san.Build()); + using X509Certificate2 ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + + // Round-trip through PKCS#12 so the private key is usable by SslStream on every OS. + byte[] pfx = ephemeral.Export(X509ContentType.Pfx); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(pfx, null); +#else + return new X509Certificate2(pfx); +#endif + } +} diff --git a/EasyFileTransfer.Tests/TestServer.cs b/EasyFileTransfer.Tests/TestServer.cs new file mode 100644 index 0000000..d83449b --- /dev/null +++ b/EasyFileTransfer.Tests/TestServer.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using System.Net; + +namespace EasyFileTransfer.Tests; + +/// An on a random loopback port with its own temp directory. +internal sealed class TestServer : IDisposable +{ + public TestServer(Action? configure = null) + { + Root = Directory.CreateTempSubdirectory("eft-tests-").FullName; + SaveDirectory = Path.Combine(Root, "inbox"); + var options = new EftServerOptions + { + SaveDirectory = SaveDirectory, + Port = 0, + BindAddress = IPAddress.Loopback, + IdleTimeout = TimeSpan.FromSeconds(10), + }; + configure?.Invoke(options); + + Server = new EftServer(options); + Server.FileReceived += (_, e) => Received.Add(e); + Server.TransferFailed += (_, e) => Failures.Add(e); + Server.Start(); + } + + /// Parent of ; used to detect writes that escape it. + public string Root { get; } + + public string SaveDirectory { get; } + + public EftServer Server { get; } + + public int Port => Server.LocalEndPoint!.Port; + + public BlockingCollection Received { get; } = new(); + + public BlockingCollection Failures { get; } = new(); + + public EftClient CreateClient(EftClientOptions? options = null) => new("127.0.0.1", Port, options); + + public TransferFailedEventArgs NextFailure() + { + Assert.True(Failures.TryTake(out var failure, TimeSpan.FromSeconds(15)), "Expected the server to report a failed transfer."); + return failure!; + } + + public string[] SavedFiles() => Directory.GetFiles(SaveDirectory).Select(Path.GetFileName).ToArray()!; + + public string[] AllFilesUnderRoot() => Directory.GetFiles(Root, "*", SearchOption.AllDirectories); + + public void Dispose() + { + Server.Dispose(); + try + { + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + } + } +} diff --git a/EasyFileTransfer.Tests/TransferTests.cs b/EasyFileTransfer.Tests/TransferTests.cs new file mode 100644 index 0000000..0325377 --- /dev/null +++ b/EasyFileTransfer.Tests/TransferTests.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; + +namespace EasyFileTransfer.Tests; + +public class TransferTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(81920)] + [InlineData(5 * 1024 * 1024 + 7)] + public async Task Sends_file_intact(int size) + { + using var server = new TestServer(); + byte[] content = RandomNumberGenerator.GetBytes(size); + string source = Path.Combine(server.Root, "source.bin"); + await File.WriteAllBytesAsync(source, content); + + var reports = new List(); + await server.CreateClient().SendFileAsync(source, new SyncProgress(reports.Add)); + + Assert.True(server.Received.TryTake(out var received, TimeSpan.FromSeconds(10))); + Assert.Equal(Path.Combine(server.SaveDirectory, "source.bin"), received!.FilePath); + Assert.Equal("source.bin", received.RequestedFileName); + Assert.Equal(size, received.Length); + Assert.Equal(content, await File.ReadAllBytesAsync(received.FilePath)); + Assert.Equal(new[] { "source.bin" }, server.SavedFiles()); + Assert.Equal(100, reports[^1].Percentage); + Assert.Equal(size, reports[^1].BytesTransferred); + } + + [Fact] + public async Task Never_overwrites_an_existing_file() + { + using var server = new TestServer(); + await File.WriteAllTextAsync(Path.Combine(server.SaveDirectory, "a.txt"), "original"); + var client = server.CreateClient(); + + await client.SendAsync(new MemoryStream("first"u8.ToArray()), "a.txt"); + await client.SendAsync(new MemoryStream("second"u8.ToArray()), "a.txt"); + + Assert.Equal("original", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a.txt"))); + Assert.Equal("first", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a (1).txt"))); + Assert.Equal("second", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a (2).txt"))); + } + + [Fact] + public async Task Handles_concurrent_transfers() + { + using var server = new TestServer(); + var client = server.CreateClient(); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(i => + client.SendAsync(new MemoryStream(RandomNumberGenerator.GetBytes(200_000)), $"file{i}.bin"))); + + Assert.Equal(8, server.SavedFiles().Length); + } + + [Fact] + public async Task Cancellation_aborts_the_transfer_and_leaves_no_partial_file() + { + using var server = new TestServer(); + using var cts = new CancellationTokenSource(); + var progress = new SyncProgress(p => + { + if (p.BytesTransferred > 1_000_000) + { + cts.Cancel(); + } + }); + + await Assert.ThrowsAnyAsync(() => + server.CreateClient().SendAsync(new MemoryStream(new byte[50_000_000]), "big.bin", progress, cts.Token)); + + server.NextFailure(); + Assert.Empty(server.SavedFiles()); + Assert.Empty(Directory.GetFiles(server.SaveDirectory, "*", new EnumerationOptions { AttributesToSkip = 0 })); + } + + [Fact] + public async Task Server_can_be_stopped_and_restarted() + { + using var server = new TestServer(); + int port = server.Port; + await server.Server.StopAsync(); + Assert.False(server.Server.IsRunning); + + server.Server.Options.Port = port; + server.Server.Start(); + await server.CreateClient().SendAsync(new MemoryStream(new byte[10]), "after-restart.bin"); + Assert.Contains("after-restart.bin", server.SavedFiles()); + } + + [Fact] + public async Task Client_rejects_unsafe_file_names_before_connecting() + { + var client = new EftClient("127.0.0.1", 1); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(), "../evil.txt")); + Assert.Equal(EftStatus.InvalidFileName, ex.Status); + } + + [Fact] + public async Task Client_times_out_against_a_silent_server() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + var client = new EftClient("127.0.0.1", port, new EftClientOptions { Timeout = TimeSpan.FromMilliseconds(500) }); + + await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + } + +#pragma warning disable CS0618 // Legacy API must keep working. + [Fact] + public async Task Legacy_api_still_works() + { + string root = Directory.CreateTempSubdirectory("eft-legacy-").FullName; + int port = GetFreePort(); + var server = new EftServer(root + Path.DirectorySeparatorChar, port); + var thread = new Thread(server.StartServer) { IsBackground = true }; + thread.Start(); + try + { + string source = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + await File.WriteAllTextAsync(source, "legacy"); + await WaitUntilAsync(() => server.IsRunning); + + Model.Response response = EftClient.Send(source, "127.0.0.1", port); + + Assert.Equal(1, response.status); + Assert.Equal(100, EftClient.ProgressValue); + Assert.Equal("legacy", await File.ReadAllTextAsync(Path.Combine(root, Path.GetFileName(source)))); + File.Delete(source); + } + finally + { + server.Dispose(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Legacy_send_reports_errors_instead_of_throwing() + { + Model.Response response = EftClient.Send("/does/not/exist.bin", "127.0.0.1", GetFreePort()); + Assert.Equal(-1, response.status); + Assert.StartsWith("Error: ", response.description); + } +#pragma warning restore CS0618 + + private static int GetFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitUntilAsync(Func condition) + { + for (int i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(50); + } + + Assert.True(condition()); + } +} + +internal sealed class SyncProgress(Action handler) : IProgress +{ + public void Report(EftProgress value) => handler(value); +} diff --git a/EasyFileTransfer.sln b/EasyFileTransfer.sln index 630a4f5..de8cc6f 100644 --- a/EasyFileTransfer.sln +++ b/EasyFileTransfer.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "Sample.Server", "Sample.Ser EndProject Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "Sample.Client", "Sample.Client\Sample.Client.csproj", "{2638D1E6-BBBC-496E-B3B8-022B0F57A242}" EndProject +Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "EasyFileTransfer.Tests", "EasyFileTransfer.Tests\EasyFileTransfer.Tests.csproj", "{E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Debug|Any CPU.Build.0 = Debug|Any CPU {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Release|Any CPU.ActiveCfg = Release|Any CPU {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Release|Any CPU.Build.0 = Release|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EasyFileTransfer/EasyFileTransfer.csproj b/EasyFileTransfer/EasyFileTransfer.csproj index 7129ef2..4727d37 100644 --- a/EasyFileTransfer/EasyFileTransfer.csproj +++ b/EasyFileTransfer/EasyFileTransfer.csproj @@ -9,21 +9,12 @@ true latest true - - CS1591;CS8600;CS8602;CS8618;CA2022 EasyFileTransfer - 0.2.0-preview.1 + 0.2.0-preview.2 EasyFileTransfer An easy way to transfer files of any size over a network using the TCP protocol. tcp;sockets;file-transfer;networking;large-files @@ -31,11 +22,15 @@ https://github.com/Jamalianpour/EasyFileTransfer README.md icon.png - Modernized build: SDK-style project, multi-targets netstandard2.0, net8.0 and net10.0. + Security rewrite: new versioned wire protocol with file-name validation (fixes path traversal), bounded reads, timeouts, connection limits, optional access-token authentication and TLS, and SHA-256 integrity checks. New async API (EftClient.SendFileAsync, EftServer.Start/StopAsync); the 0.1.x API remains as [Obsolete] wrappers. Not wire-compatible with 0.1.x. true snupkg + + + + diff --git a/EasyFileTransfer/EftClient.cs b/EasyFileTransfer/EftClient.cs index a0424a4..f802085 100644 --- a/EasyFileTransfer/EftClient.cs +++ b/EasyFileTransfer/EftClient.cs @@ -1,128 +1,291 @@ -using EasyFileTransfer.Model; +using EasyFileTransfer.Internal; +using EasyFileTransfer.Model; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; +using System.Net.Security; using System.Net.Sockets; -using System.Text; +using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; namespace EasyFileTransfer { + /// + /// Sends files to an . + /// + /// + /// + /// var client = new EftClient("192.168.1.10", 1300, new EftClientOptions { AccessToken = token }); + /// await client.SendFileAsync(@"C:\big.iso", new Progress<EftProgress>(p => Console.WriteLine(p.Percentage))); + /// + /// public class EftClient { + private readonly EftClientOptions _options; + /// - /// Percentage of file send progress + /// Creates a client for the server at :. /// - public static int ProgressValue; + /// Host name or IP address of the server. + /// Port the server listens on. + /// Optional settings such as the access token and TLS. + public EftClient(string host, int port, EftClientOptions? options = null) + { + if (string.IsNullOrWhiteSpace(host)) + { + throw new ArgumentException("Host is required.", nameof(host)); + } + + if (port < 1 || port > 65535) + { + throw new ArgumentOutOfRangeException(nameof(port)); + } + + _options = options ?? new EftClientOptions(); + _options.Validate(); + Host = host; + Port = port; + } + + /// Host name or IP address of the server. + public string Host { get; } + + /// Port the server listens on. + public int Port { get; } /// - /// send file + /// Sends the file at . The server stores it under the same file name. /// - /// file location that you want to send - /// IP Address of target system - /// Server listen to this port - /// An object with status and description - public static Response Send(string FilePath, string TargetIP, int Port) + /// Path of the file to send. + /// Receives progress updates; may be null. + /// Aborts the transfer. + /// The server rejected the file or the transfer failed its integrity check. + /// A network or disk error occurred. + /// A network operation took longer than . + public async Task SendFileAsync(string filePath, IProgress? progress = null, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrEmpty(filePath)) { - string Selected_file = FilePath; - string File_name = Path.GetFileName(Selected_file); - FileStream fs = new FileStream(Selected_file, FileMode.Open); - TcpClient tc = new TcpClient(TargetIP, Port); - NetworkStream ns = tc.GetStream(); - byte[] data_tosend = CreateDataPacket(Encoding.UTF8.GetBytes("125"), Encoding.UTF8.GetBytes(File_name)); - ns.Write(data_tosend, 0, data_tosend.Length); - ns.Flush(); - bool loop_break = false; - while (true) + throw new ArgumentException("File path is required.", nameof(filePath)); + } + + using (var file = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, Protocol.BufferSize, useAsync: true)) + { + await SendAsync(file, Path.GetFileName(filePath), progress, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Sends the remaining contents of (from its current position to its end) + /// as a file called . + /// + /// A readable, seekable stream. + /// Name the server stores the file under. Must not contain path separators. + /// Receives progress updates; may be null. + /// Aborts the transfer. + /// The server rejected the file or the transfer failed its integrity check. + /// A network or disk error occurred. + /// A network operation took longer than . + public async Task SendAsync(Stream content, string fileName, IProgress? progress = null, CancellationToken cancellationToken = default) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (!content.CanRead || !content.CanSeek) + { + throw new ArgumentException("The stream must be readable and seekable.", nameof(content)); + } + + if (!FileNameValidator.IsValid(fileName)) + { + throw new EftException(EftStatus.InvalidFileName, $"'{fileName}' is not a valid file name."); + } + + byte[] nameBytes = Protocol.StrictUtf8.GetBytes(fileName); + if (nameBytes.Length > Protocol.MaxFileNameBytes) + { + throw new EftException(EftStatus.InvalidFileName, "The file name is too long."); + } + + cancellationToken.ThrowIfCancellationRequested(); + long length = content.Length - content.Position; + + using (var connection = new Connection(new TcpClient(), _options.Timeout, cancellationToken)) + { + try + { + await TransferAsync(connection, content, length, nameBytes, progress).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is EftException)) { - if (ns.ReadByte() == 2) + Exception translated = connection.Translate(ex); + if (ReferenceEquals(translated, ex)) { - byte[] cmd_buffer = new byte[3]; - ns.Read(cmd_buffer, 0, cmd_buffer.Length); - byte[] recv_data = ReadStream(ns); - switch (Convert.ToInt32(Encoding.UTF8.GetString(cmd_buffer))) - { - case 126: - long recv_file_pointer = long.Parse(Encoding.UTF8.GetString(recv_data)); - if (recv_file_pointer != fs.Length) - { - fs.Seek(recv_file_pointer, SeekOrigin.Begin); - int temp_buffer_length = (int)(fs.Length - recv_file_pointer < 20000 ? fs.Length - recv_file_pointer : 20000); - byte[] temp_buffer = new byte[temp_buffer_length]; - fs.Read(temp_buffer, 0, temp_buffer.Length); - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("127"), temp_buffer); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - ProgressValue = (int)Math.Ceiling((double)recv_file_pointer / (double)fs.Length * 100); - } - else - { - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("128"), Encoding.UTF8.GetBytes("Close")); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - fs.Close(); - loop_break = true; - } - break; - default: - break; - } + throw; } - if (loop_break == true) + + throw translated; + } + } + } + + private async Task TransferAsync(Connection connection, Stream content, long length, byte[] nameBytes, IProgress? progress) + { + await ConnectAsync(connection).ConfigureAwait(false); + + // Server hello. + var hello = new byte[Protocol.HelloLength]; + await connection.ReadExactlyAsync(hello, 0, hello.Length).ConfigureAwait(false); + if (!Protocol.HasMagic(hello, 0)) + { + throw new EftException(EftStatus.ProtocolError, "The remote endpoint is not an EasyFileTransfer server."); + } + + if (hello[Protocol.MagicLength] != Protocol.Version) + { + throw new EftException(EftStatus.UnsupportedVersion, $"The server speaks protocol version {hello[Protocol.MagicLength]}; this client requires version {Protocol.Version}."); + } + + ThrowIfNotSuccess(hello[Protocol.MagicLength + 1]); + + bool authRequired = (hello[Protocol.MagicLength + 2] & Protocol.FlagAuthRequired) != 0; + if (authRequired && _options.AccessToken is null) + { + throw new EftException(EftStatus.AuthenticationFailed, "The server requires an access token. Set EftClientOptions.AccessToken."); + } + + var nonce = new byte[Protocol.NonceLength]; + Buffer.BlockCopy(hello, Protocol.MagicLength + 3, nonce, 0, nonce.Length); + + // Request. + var request = new byte[Protocol.RequestHeaderLength + nameBytes.Length + 8]; + Protocol.WriteMagic(request, 0); + request[Protocol.MagicLength] = Protocol.Version; + byte[] proof = Protocol.ComputeProof(_options.AccessToken, nonce); + Buffer.BlockCopy(proof, 0, request, Protocol.MagicLength + 1, Protocol.ProofLength); + Protocol.WriteUInt16(request, Protocol.RequestHeaderLength - 2, nameBytes.Length); + Buffer.BlockCopy(nameBytes, 0, request, Protocol.RequestHeaderLength, nameBytes.Length); + Protocol.WriteInt64(request, Protocol.RequestHeaderLength + nameBytes.Length, length); + await connection.WriteAsync(request, 0, request.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + + await ReadStatusAsync(connection).ConfigureAwait(false); + + // File content followed by its SHA-256. + var buffer = new byte[Protocol.BufferSize]; + long sent = 0; + int lastReportedPercent = -1; + using (var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) + { + progress?.Report(new EftProgress(0, length)); + while (sent < length) + { + int toRead = (int)Math.Min(buffer.Length, length - sent); + int read = await content.ReadAsync(buffer, 0, toRead, connection.Token).ConfigureAwait(false); + if (read == 0) { - ns.Close(); - return new Response { status = 1, description = "send successfully."}; + throw new EndOfStreamException("The source stream ended before all bytes were sent."); } + hash.AppendData(buffer, 0, read); + await connection.WriteAsync(buffer, 0, read).ConfigureAwait(false); + sent += read; + + int percent = (int)(sent * 100 / Math.Max(length, 1)); + if (progress != null && percent != lastReportedPercent) + { + lastReportedPercent = percent; + progress.Report(new EftProgress(sent, length)); + } } + + byte[] digest = hash.GetHashAndReset(); + await connection.WriteAsync(digest, 0, digest.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); } - catch (Exception e) + + await ReadStatusAsync(connection).ConfigureAwait(false); + if (length == 0) { - return new Response { status = -1, description = "Error: " + e.Message}; + progress?.Report(new EftProgress(0, 0)); } } - private static byte[] ReadStream(NetworkStream ns) + private async Task ConnectAsync(Connection connection) { - byte[] data_buff = null; + TcpClient tcp = connection.TcpClient; + connection.ResetTimer(); + await tcp.ConnectAsync(Host, Port).ConfigureAwait(false); + tcp.NoDelay = true; + + Stream stream = tcp.GetStream(); + connection.Stream = stream; - int b = 0; - string buff_Length = ""; - while ((b = ns.ReadByte()) != 4) + if (_options.UseTls) { - buff_Length += (char)b; + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, _options.ServerCertificateValidationCallback); + connection.Stream = ssl; + connection.ResetTimer(); + await ssl.AuthenticateAsClientAsync(_options.TlsTargetHost ?? Host).ConfigureAwait(false); } - int data_Length = Convert.ToInt32(buff_Length); - data_buff = new byte[data_Length]; - int byte_Read = 0; - int byte_Offset = 0; - while (byte_Offset < data_Length) + } + + private static async Task ReadStatusAsync(Connection connection) + { + var status = new byte[1]; + await connection.ReadExactlyAsync(status, 0, 1).ConfigureAwait(false); + ThrowIfNotSuccess(status[0]); + } + + private static void ThrowIfNotSuccess(byte status) + { + if (status != (byte)EftStatus.Success) { - byte_Read = ns.Read(data_buff, byte_Offset, data_Length - byte_Offset); - byte_Offset += byte_Read; + throw new EftException((EftStatus)status); } + } + + #region Legacy API (0.1.x) - return data_buff; + /// + /// Percentage of file send progress for the most recent call. + /// + [Obsolete("Shared static state is not thread-safe. Pass an IProgress to SendFileAsync instead.")] + public static int ProgressValue; + + /// + /// Sends a file and blocks until the transfer completes. + /// + /// file location that you want to send + /// IP Address of target system + /// Server listen to this port + /// An object with status and description + [Obsolete("Use new EftClient(host, port, options).SendFileAsync(path) instead.")] + public static Response Send(string FilePath, string TargetIP, int Port) + { + try + { + var progress = new SynchronousProgress(p => ProgressValue = (int)Math.Ceiling(p.Percentage)); + new EftClient(TargetIP, Port).SendFileAsync(FilePath, progress).GetAwaiter().GetResult(); + return new Response { status = 1, description = "send successfully." }; + } + catch (Exception e) + { + return new Response { status = -1, description = "Error: " + e.Message }; + } } - private static byte[] CreateDataPacket(byte[] cmd, byte[] data) + private sealed class SynchronousProgress : IProgress { - byte[] initialize = new byte[1]; - initialize[0] = 2; - byte[] separator = new byte[1]; - separator[0] = 4; - byte[] dataLength = Encoding.UTF8.GetBytes(Convert.ToString(data.Length)); - MemoryStream ms = new MemoryStream(); - ms.Write(initialize, 0, initialize.Length); - ms.Write(cmd, 0, cmd.Length); - ms.Write(dataLength, 0, dataLength.Length); - ms.Write(separator, 0, separator.Length); - ms.Write(data, 0, data.Length); - - return ms.ToArray(); + private readonly Action _handler; + + public SynchronousProgress(Action handler) => _handler = handler; + + public void Report(EftProgress value) => _handler(value); } + + #endregion } } diff --git a/EasyFileTransfer/EftClientOptions.cs b/EasyFileTransfer/EftClientOptions.cs new file mode 100644 index 0000000..9be0008 --- /dev/null +++ b/EasyFileTransfer/EftClientOptions.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Security; + +namespace EasyFileTransfer +{ + /// + /// Settings for . + /// + public sealed class EftClientOptions + { + /// + /// Shared secret expected by the server (). + /// The token itself is never sent; the client proves it knows the token with an + /// HMAC-SHA256 over a random challenge chosen by the server. + /// + public string? AccessToken { get; set; } + + /// + /// Encrypt the connection with TLS. The server must be configured with + /// . + /// + public bool UseTls { get; set; } + + /// + /// Host name the server certificate must match. Defaults to the host passed to + /// the constructor. + /// + public string? TlsTargetHost { get; set; } + + /// + /// Custom server certificate validation, for example to pin a self-signed + /// certificate. When null, the operating system's standard validation is used. + /// + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// + /// Maximum time a single network operation (connect, handshake, read or write) + /// may take before the transfer is aborted. Defaults to 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + internal void Validate() + { + if (Timeout <= TimeSpan.Zero && Timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(Timeout), "Timeout must be positive or Timeout.InfiniteTimeSpan."); + } + } + } +} diff --git a/EasyFileTransfer/EftException.cs b/EasyFileTransfer/EftException.cs new file mode 100644 index 0000000..fb1d161 --- /dev/null +++ b/EasyFileTransfer/EftException.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; + +namespace EasyFileTransfer +{ + /// + /// Thrown when a transfer is rejected or fails at the protocol level. + /// Network failures surface as or . + /// + public class EftException : IOException + { + /// Creates a new . + public EftException(EftStatus status) + : this(status, DescribeStatus(status)) + { + } + + /// Creates a new with a custom message. + public EftException(EftStatus status, string message) + : base(message) + { + Status = status; + } + + /// Creates a new with a custom message and inner exception. + public EftException(EftStatus status, string message, Exception innerException) + : base(message, innerException) + { + Status = status; + } + + /// The reason the transfer failed. + public EftStatus Status { get; } + + internal static string DescribeStatus(EftStatus status) + { + switch (status) + { + case EftStatus.Success: return "The operation completed successfully."; + case EftStatus.ServerBusy: return "The server is busy. Try again later."; + case EftStatus.AuthenticationFailed: return "The server rejected the access token."; + case EftStatus.InvalidFileName: return "The file name is not allowed."; + case EftStatus.FileTooLarge: return "The file is larger than the server allows."; + case EftStatus.InsufficientStorage: return "The server does not have enough free disk space."; + case EftStatus.IntegrityCheckFailed: return "The file was corrupted in transit (SHA-256 mismatch)."; + case EftStatus.ProtocolError: return "The peer sent an invalid message."; + case EftStatus.ServerError: return "The server failed to store the file."; + case EftStatus.UnsupportedVersion: return "The peer uses an unsupported protocol version."; + default: return "Unknown status " + (byte)status + "."; + } + } + } +} diff --git a/EasyFileTransfer/EftProgress.cs b/EasyFileTransfer/EftProgress.cs new file mode 100644 index 0000000..76d1506 --- /dev/null +++ b/EasyFileTransfer/EftProgress.cs @@ -0,0 +1,24 @@ +namespace EasyFileTransfer +{ + /// + /// Progress of a file transfer, reported through . + /// + public readonly struct EftProgress + { + /// Creates a new progress value. + public EftProgress(long bytesTransferred, long totalBytes) + { + BytesTransferred = bytesTransferred; + TotalBytes = totalBytes; + } + + /// Number of file bytes sent so far. + public long BytesTransferred { get; } + + /// Total size of the file in bytes. + public long TotalBytes { get; } + + /// Completion percentage between 0 and 100. + public double Percentage => TotalBytes == 0 ? 100 : BytesTransferred * 100.0 / TotalBytes; + } +} diff --git a/EasyFileTransfer/EftServer.cs b/EasyFileTransfer/EftServer.cs index d48a77f..527d0a3 100644 --- a/EasyFileTransfer/EftServer.cs +++ b/EasyFileTransfer/EftServer.cs @@ -1,144 +1,514 @@ -using System; -using System.Collections.Generic; +using EasyFileTransfer.Internal; +using System; +using System.Collections.Concurrent; using System.IO; -using System.Linq; using System.Net; +using System.Net.Security; using System.Net.Sockets; -using System.Text; +using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; namespace EasyFileTransfer { - public class EftServer + /// + /// Receives files sent by and stores them in + /// . + /// + /// + /// + /// using var server = new EftServer(new EftServerOptions { SaveDirectory = @"C:\Inbox", AccessToken = token }); + /// server.FileReceived += (s, e) => Console.WriteLine($"Saved {e.FilePath}"); + /// server.Start(); + /// + /// + public class EftServer : IDisposable { - public string SaveTo; - public int Port; - TcpListener obj_server; - public EftServer(string SaveTo, int Port) + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + private readonly object _gate = new object(); + private readonly ConcurrentDictionary _connections = new ConcurrentDictionary(); + private TcpListener? _listener; + private CancellationTokenSource? _stopCts; + private Task? _acceptLoop; + private string _saveDirectory = string.Empty; + private int _activeConnections; + + /// + /// Creates a server. Call to begin accepting files. + /// + public EftServer(EftServerOptions options) { - this.SaveTo = SaveTo; - this.Port = Port; - obj_server = new TcpListener(IPAddress.Any, Port); + Options = options ?? throw new ArgumentNullException(nameof(options)); + options.Validate(); } + /// The server's settings. Change them only while the server is stopped. + public EftServerOptions Options { get; } + + /// The address and port the server is listening on, or null when stopped. + public IPEndPoint? LocalEndPoint { get; private set; } + + /// Whether the server is accepting connections. + public bool IsRunning => _listener != null; + /// - /// Start EftServer and listening on port + /// Raised on a thread-pool thread after a file has been received, verified and saved. + /// Exceptions thrown by handlers are ignored. /// - public void StartServer() + public event EventHandler? FileReceived; + + /// + /// Raised on a thread-pool thread when a transfer is rejected or fails. Any partially + /// written data has already been deleted. Exceptions thrown by handlers are ignored. + /// + public event EventHandler? TransferFailed; + + /// + /// Starts listening for connections in the background and returns immediately. + /// + /// The server is already running. + /// The port could not be opened. + public void Start() { - obj_server.Start(); - while (true) + lock (_gate) { - TcpClient tc = obj_server.AcceptTcpClient(); - SocketHandler obj_hadler = new SocketHandler(tc, SaveTo); - System.Threading.Thread obj_thread = new System.Threading.Thread(obj_hadler.ProcessSocketRequest); - obj_thread.Start(); + if (_listener != null) + { + throw new InvalidOperationException("The server is already running."); + } + + Options.Validate(); + _saveDirectory = Path.GetFullPath(Options.SaveDirectory); + Directory.CreateDirectory(_saveDirectory); + + var listener = new TcpListener(Options.BindAddress, Options.Port); + if (Options.BindAddress.Equals(IPAddress.IPv6Any)) + { + listener.Server.DualMode = true; + } + + listener.Start(); + var stopCts = new CancellationTokenSource(); + _listener = listener; + _stopCts = stopCts; + LocalEndPoint = (IPEndPoint)listener.LocalEndpoint; + _acceptLoop = Task.Run(() => AcceptLoopAsync(listener, stopCts.Token)); } } - } - class SocketHandler - { - NetworkStream ns; - string SaveTo; - public SocketHandler(TcpClient tc, string SaveTo) + /// + /// Stops accepting connections, aborts transfers in progress and waits for them to finish. + /// + public async Task StopAsync() { - this.SaveTo = SaveTo; - ns = tc.GetStream(); + Task? acceptLoop; + CancellationTokenSource? stopCts; + lock (_gate) + { + if (_listener == null) + { + return; + } + + stopCts = _stopCts!; + acceptLoop = _acceptLoop; + stopCts.Cancel(); + _listener.Stop(); + _listener = null; + _stopCts = null; + _acceptLoop = null; + LocalEndPoint = null; + } + + if (acceptLoop != null) + { + await acceptLoop.ConfigureAwait(false); + } + + await Task.WhenAll(_connections.Keys).ConfigureAwait(false); + stopCts.Dispose(); } - public void ProcessSocketRequest() + /// Stops the server. See . + public void Dispose() { - FileStream fs = null; - long current_file_pointer = 0; - Boolean loop_break = false; - while (true) + StopAsync().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } + + private async Task AcceptLoopAsync(TcpListener listener, CancellationToken stopToken) + { + while (!stopToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (Exception) when (stopToken.IsCancellationRequested) + { + break; + } + catch (SocketException) + { + // A client that resets before the accept completes is not a server failure. + continue; + } + + Task connection = Task.Run(() => HandleConnectionAsync(client, stopToken)); + _connections.TryAdd(connection, 0); + _ = connection.ContinueWith(t => _connections.TryRemove(t, out _), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + } + + private async Task HandleConnectionAsync(TcpClient client, CancellationToken stopToken) + { + var transfer = new Transfer(); + bool busy = Interlocked.Increment(ref _activeConnections) > Options.MaxConcurrentConnections; + var connection = new Connection(client, busy ? BusyTimeout : Options.IdleTimeout, stopToken); + Exception? failure = null; + try + { + transfer.RemoteEndPoint = client.Client.RemoteEndPoint as IPEndPoint; + client.NoDelay = true; + await ReceiveAsync(connection, busy, transfer).ConfigureAwait(false); + } + catch (Exception ex) + { + failure = connection.Translate(ex); + } + finally + { + connection.Dispose(); + Interlocked.Decrement(ref _activeConnections); + DeleteQuietly(transfer.TempPath); + } + + // Raised only after cleanup, so handlers never see a leftover temp file or a stale connection count. + if (failure != null && !stopToken.IsCancellationRequested) + { + RaiseTransferFailed(failure, transfer.RemoteEndPoint); + } + } + + private async Task ReceiveAsync(Connection connection, bool busy, Transfer transfer) + { + Stream stream = connection.TcpClient.GetStream(); + connection.Stream = stream; + if (Options.ServerCertificate != null) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + connection.Stream = ssl; + connection.ResetTimer(); + await ssl.AuthenticateAsServerAsync(Options.ServerCertificate).ConfigureAwait(false); + } + + // Hello. + byte[] nonce = Protocol.CreateNonce(); + var hello = new byte[Protocol.HelloLength]; + Protocol.WriteMagic(hello, 0); + hello[Protocol.MagicLength] = Protocol.Version; + hello[Protocol.MagicLength + 1] = (byte)(busy ? EftStatus.ServerBusy : EftStatus.Success); + hello[Protocol.MagicLength + 2] = Options.AccessToken != null ? Protocol.FlagAuthRequired : (byte)0; + Buffer.BlockCopy(nonce, 0, hello, Protocol.MagicLength + 3, nonce.Length); + await connection.WriteAsync(hello, 0, hello.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + if (busy) + { + throw new EftException(EftStatus.ServerBusy); + } + + // Request header. Every length is checked before anything is allocated from it. + var header = new byte[Protocol.RequestHeaderLength]; + await connection.ReadExactlyAsync(header, 0, header.Length).ConfigureAwait(false); + if (!Protocol.HasMagic(header, 0)) + { + await RejectAsync(connection, EftStatus.ProtocolError).ConfigureAwait(false); + } + + if (header[Protocol.MagicLength] != Protocol.Version) + { + await RejectAsync(connection, EftStatus.UnsupportedVersion).ConfigureAwait(false); + } + + int nameLength = Protocol.ReadUInt16(header, Protocol.RequestHeaderLength - 2); + if (nameLength == 0 || nameLength > Protocol.MaxFileNameBytes) + { + await RejectAsync(connection, EftStatus.InvalidFileName).ConfigureAwait(false); + } + + var rest = new byte[nameLength + 8]; + await connection.ReadExactlyAsync(rest, 0, rest.Length).ConfigureAwait(false); + + if (Options.AccessToken != null) + { + byte[] expectedProof = Protocol.ComputeProof(Options.AccessToken, nonce); + if (!Protocol.FixedTimeEquals(header, Protocol.MagicLength + 1, expectedProof, Protocol.ProofLength)) + { + await RejectAsync(connection, EftStatus.AuthenticationFailed).ConfigureAwait(false); + } + } + + string fileName; + try + { + fileName = Protocol.StrictUtf8.GetString(rest, 0, nameLength); + } + catch (ArgumentException) + { + fileName = string.Empty; + } + + if (!FileNameValidator.IsValid(fileName) || !IsInsideSaveDirectory(fileName)) + { + await RejectAsync(connection, EftStatus.InvalidFileName).ConfigureAwait(false); + } + + long length = Protocol.ReadInt64(rest, nameLength); + if (length < 0) + { + await RejectAsync(connection, EftStatus.ProtocolError).ConfigureAwait(false); + } + + if (length > Options.MaxFileSize) + { + await RejectAsync(connection, EftStatus.FileTooLarge).ConfigureAwait(false); + } + + if (length > GetAvailableFreeSpace()) + { + await RejectAsync(connection, EftStatus.InsufficientStorage).ConfigureAwait(false); + } + + // Receive into a hidden temp file so a partial or corrupt upload never appears under + // its real name. FileMode.CreateNew never follows or overwrites an existing entry. + string tempPath = Path.Combine(_saveDirectory, "." + Guid.NewGuid().ToString("N") + ".eftpart"); + FileStream? tempFile = null; + try + { + tempFile = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, Protocol.BufferSize, useAsync: true); + transfer.TempPath = tempPath; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + await RejectAsync(connection, EftStatus.ServerError, ex).ConfigureAwait(false); + } + + byte[] actualHash; + using (var file = tempFile!) { - //byte[] readPbValue = ReadStream(); - if (ns.ReadByte() == 2) + await WriteStatusAsync(connection, EftStatus.Success).ConfigureAwait(false); + + var buffer = new byte[Protocol.BufferSize]; + long remaining = length; + using (var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) { - byte[] cmd_buffer = new byte[3]; - ns.Read(cmd_buffer, 0, cmd_buffer.Length); - byte[] recv_data = ReadStream(); - switch (Convert.ToInt32(Encoding.UTF8.GetString(cmd_buffer))) + while (remaining > 0) { - case 101: - //download++; - break; - case 125: - { - fs = new FileStream(@"" + SaveTo + Encoding.UTF8.GetString(recv_data), FileMode.CreateNew); - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("126"), Encoding.UTF8.GetBytes(Convert.ToString(current_file_pointer))); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - } - break; - case 127: - { - fs.Seek(current_file_pointer, SeekOrigin.Begin); - fs.Write(recv_data, 0, recv_data.Length); - current_file_pointer = fs.Position; - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("126"), Encoding.UTF8.GetBytes(Convert.ToString(current_file_pointer))); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - } - break; - case 128: - { - fs.Close(); - loop_break = true; - } - break; - default: - break; + int read = await connection.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, remaining)).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("The client disconnected before sending the whole file."); + } + + hash.AppendData(buffer, 0, read); + await file.WriteAsync(buffer, 0, read, connection.Token).ConfigureAwait(false); + remaining -= read; } + + actualHash = hash.GetHashAndReset(); } - if (loop_break == true) + + await file.FlushAsync(connection.Token).ConfigureAwait(false); + } + + var expectedHash = new byte[Protocol.HashLength]; + await connection.ReadExactlyAsync(expectedHash, 0, expectedHash.Length).ConfigureAwait(false); + if (!Protocol.FixedTimeEquals(expectedHash, 0, actualHash, Protocol.HashLength)) + { + await RejectAsync(connection, EftStatus.IntegrityCheckFailed).ConfigureAwait(false); + } + + string finalPath; + try + { + finalPath = MoveToUniqueName(tempPath, fileName); + transfer.TempPath = null; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + await RejectAsync(connection, EftStatus.ServerError, ex).ConfigureAwait(false); + throw; + } + + await WriteStatusAsync(connection, EftStatus.Success).ConfigureAwait(false); + RaiseFileReceived(new FileReceivedEventArgs(finalPath, fileName, length, transfer.RemoteEndPoint)); + } + + private static async Task WriteStatusAsync(Connection connection, EftStatus status) + { + await connection.WriteAsync(new[] { (byte)status }, 0, 1).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + } + + /// Tells the client why its request was refused, then aborts the connection. + private static async Task RejectAsync(Connection connection, EftStatus status, Exception? innerException = null) + { + try + { + await WriteStatusAsync(connection, status).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException || ex is ObjectDisposedException || ex is OperationCanceledException) + { + // The client is gone; the rejection below is still what gets reported. + } + + throw innerException is null + ? new EftException(status) + : new EftException(status, EftException.DescribeStatus(status), innerException); + } + + /// Defense in depth: the validated name must resolve to a direct child of the save directory. + private bool IsInsideSaveDirectory(string fileName) + { + string fullPath = Path.GetFullPath(Path.Combine(_saveDirectory, fileName)); + string? parent = Path.GetDirectoryName(fullPath); + return parent != null + && string.Equals( + parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + _saveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.Ordinal); + } + + private long GetAvailableFreeSpace() + { + try + { + return new DriveInfo(_saveDirectory).AvailableFreeSpace; + } + catch (Exception ex) when (ex is ArgumentException || ex is IOException || ex is UnauthorizedAccessException) + { + // Network shares and some mounts cannot be queried; let the write itself fail if space runs out. + return long.MaxValue; + } + } + + /// Moves the temp file to , appending " (n)" instead of overwriting. + private string MoveToUniqueName(string tempPath, string fileName) + { + string stem = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + for (int i = 0; i < 10000; i++) + { + string candidate = i == 0 ? fileName : $"{stem} ({i}){extension}"; + string destination = Path.Combine(_saveDirectory, candidate); + if (File.Exists(destination) || Directory.Exists(destination)) { - ns.Close(); - break; + continue; + } + + try + { + File.Move(tempPath, destination); + return destination; + } + catch (IOException) when (File.Exists(destination) || Directory.Exists(destination)) + { + // Another transfer claimed the name first. } } + + throw new IOException($"Could not find a free file name for '{fileName}'."); } - public byte[] ReadStream() + private static void DeleteQuietly(string? path) { - byte[] data_buff = null; + if (path == null) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + // Best effort; the hidden .eftpart file can be removed manually. + } + } + + private void RaiseFileReceived(FileReceivedEventArgs args) + { + try + { + FileReceived?.Invoke(this, args); + } +#pragma warning disable CA1031 // A faulty event handler must not take the server down. + catch (Exception) +#pragma warning restore CA1031 + { + } + } - int b = 0; - string buff_Length = ""; - while ((b = ns.ReadByte()) != 4) + private void RaiseTransferFailed(Exception exception, IPEndPoint? remoteEndPoint) + { + try { - buff_Length += (char)b; + TransferFailed?.Invoke(this, new TransferFailedEventArgs(exception, remoteEndPoint)); } - int data_Length = Convert.ToInt32(buff_Length); - data_buff = new byte[data_Length]; - int byte_Read = 0; - int byte_Offset = 0; - while (byte_Offset < data_Length) +#pragma warning disable CA1031 // A faulty event handler must not take the server down. + catch (Exception) +#pragma warning restore CA1031 { - byte_Read = ns.Read(data_buff, byte_Offset, data_Length - byte_Offset); - byte_Offset += byte_Read; } + } - return data_buff; + #region Legacy API (0.1.x) + + /// + /// Creates a server that saves files to and listens on . + /// + [Obsolete("Use new EftServer(new EftServerOptions { SaveDirectory = ..., Port = ... }) instead.")] + public EftServer(string SaveTo, int Port) + : this(new EftServerOptions { SaveDirectory = SaveTo, Port = Port }) + { } - private byte[] CreateDataPacket(byte[] cmd, byte[] data) + /// Directory received files are saved to. + [Obsolete("Use Options.SaveDirectory instead.")] + public string SaveTo => Options.SaveDirectory; + + /// Port the server listens on. + [Obsolete("Use Options.Port instead.")] + public int Port => Options.Port; + + /// + /// Starts the server and blocks the calling thread until or + /// is called. + /// + [Obsolete("Use Start() (non-blocking) and StopAsync() instead.")] + public void StartServer() + { + Start(); + Task? acceptLoop; + lock (_gate) + { + acceptLoop = _acceptLoop; + } + + acceptLoop?.GetAwaiter().GetResult(); + } + + #endregion + + private sealed class Transfer { - byte[] initialize = new byte[1]; - initialize[0] = 2; - byte[] separator = new byte[1]; - separator[0] = 4; - byte[] dataLength = Encoding.UTF8.GetBytes(Convert.ToString(data.Length)); - MemoryStream ms = new MemoryStream(); - ms.Write(initialize, 0, initialize.Length); - ms.Write(cmd, 0, cmd.Length); - ms.Write(dataLength, 0, dataLength.Length); - ms.Write(separator, 0, separator.Length); - ms.Write(data, 0, data.Length); + public IPEndPoint? RemoteEndPoint { get; set; } - return ms.ToArray(); + /// Temp file to delete when the transfer does not complete. + public string? TempPath { get; set; } } } } diff --git a/EasyFileTransfer/EftServerOptions.cs b/EasyFileTransfer/EftServerOptions.cs new file mode 100644 index 0000000..9391e75 --- /dev/null +++ b/EasyFileTransfer/EftServerOptions.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Net; +using System.Security.Cryptography.X509Certificates; + +namespace EasyFileTransfer +{ + /// + /// Settings for . + /// + public sealed class EftServerOptions + { + /// + /// Directory received files are written to. It is created if it does not exist. + /// Files are never written outside this directory. + /// + public string SaveDirectory { get; set; } = string.Empty; + + /// TCP port to listen on. Use 0 to let the operating system pick a free port. + public int Port { get; set; } = 1300; + + /// + /// Local address to listen on. Defaults to (all IPv4 interfaces); + /// use to accept only local connections. + /// + public IPAddress BindAddress { get; set; } = IPAddress.Any; + + /// + /// Shared secret clients must know to upload files. When null (the default) + /// anyone who can reach the port can upload. Strongly recommended on any + /// network you do not fully trust; use a long random value. + /// + public string? AccessToken { get; set; } + + /// + /// Certificate (with private key) used to encrypt connections with TLS. When null + /// (the default) files travel over the network unencrypted. + /// + public X509Certificate? ServerCertificate { get; set; } + + /// Largest file, in bytes, the server accepts. null means no limit. + public long? MaxFileSize { get; set; } + + /// Maximum number of connections handled at the same time. Defaults to 10. + public int MaxConcurrentConnections { get; set; } = 10; + + /// + /// Maximum time the server waits on a single network operation before dropping the + /// connection. Defaults to 30 seconds. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30); + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(SaveDirectory)) + { + throw new ArgumentException("SaveDirectory is required.", nameof(SaveDirectory)); + } + + if (Port < IPEndPoint.MinPort || Port > IPEndPoint.MaxPort) + { + throw new ArgumentOutOfRangeException(nameof(Port)); + } + + if (BindAddress is null) + { + throw new ArgumentNullException(nameof(BindAddress)); + } + + if (AccessToken is not null && AccessToken.Length == 0) + { + throw new ArgumentException("AccessToken must not be empty. Use null to disable authentication.", nameof(AccessToken)); + } + + if (MaxFileSize < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaxFileSize)); + } + + if (MaxConcurrentConnections < 1) + { + throw new ArgumentOutOfRangeException(nameof(MaxConcurrentConnections)); + } + + if (IdleTimeout <= TimeSpan.Zero && IdleTimeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(IdleTimeout)); + } + + _ = Path.GetFullPath(SaveDirectory); + } + } +} diff --git a/EasyFileTransfer/EftStatus.cs b/EasyFileTransfer/EftStatus.cs new file mode 100644 index 0000000..800c081 --- /dev/null +++ b/EasyFileTransfer/EftStatus.cs @@ -0,0 +1,38 @@ +namespace EasyFileTransfer +{ + /// + /// Status codes exchanged between and . + /// + public enum EftStatus : byte + { + /// The operation completed successfully. + Success = 0, + + /// The server has reached its concurrent connection limit. + ServerBusy = 1, + + /// The access token was missing or did not match the server's token. + AuthenticationFailed = 2, + + /// The file name is empty, too long, or contains characters that are not allowed. + InvalidFileName = 3, + + /// The file exceeds the server's . + FileTooLarge = 4, + + /// The server does not have enough free disk space for the file. + InsufficientStorage = 5, + + /// The SHA-256 hash of the received data did not match the hash sent by the client. + IntegrityCheckFailed = 6, + + /// The peer sent data that does not follow the EasyFileTransfer protocol. + ProtocolError = 7, + + /// The server failed to store the file. + ServerError = 8, + + /// The peer speaks a protocol version this library does not support. + UnsupportedVersion = 9, + } +} diff --git a/EasyFileTransfer/FileReceivedEventArgs.cs b/EasyFileTransfer/FileReceivedEventArgs.cs new file mode 100644 index 0000000..402bbd4 --- /dev/null +++ b/EasyFileTransfer/FileReceivedEventArgs.cs @@ -0,0 +1,48 @@ +using System; +using System.Net; + +namespace EasyFileTransfer +{ + /// Data for the event. + public sealed class FileReceivedEventArgs : EventArgs + { + internal FileReceivedEventArgs(string filePath, string requestedFileName, long length, IPEndPoint? remoteEndPoint) + { + FilePath = filePath; + RequestedFileName = requestedFileName; + Length = length; + RemoteEndPoint = remoteEndPoint; + } + + /// Full path of the saved file. A suffix such as " (1)" is added when a file with the same name already exists. + public string FilePath { get; } + + /// File name sent by the client. + public string RequestedFileName { get; } + + /// Size of the file in bytes. + public long Length { get; } + + /// Address of the client that sent the file. + public IPEndPoint? RemoteEndPoint { get; } + } + + /// Data for the event. + public sealed class TransferFailedEventArgs : EventArgs + { + internal TransferFailedEventArgs(Exception exception, IPEndPoint? remoteEndPoint) + { + Exception = exception; + RemoteEndPoint = remoteEndPoint; + } + + /// + /// Why the transfer failed. An means the server rejected the + /// request; other exceptions are network or disk errors. + /// + public Exception Exception { get; } + + /// Address of the client, when known. + public IPEndPoint? RemoteEndPoint { get; } + } +} diff --git a/EasyFileTransfer/Internal/Connection.cs b/EasyFileTransfer/Internal/Connection.cs new file mode 100644 index 0000000..1e693fa --- /dev/null +++ b/EasyFileTransfer/Internal/Connection.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace EasyFileTransfer.Internal +{ + /// + /// A TCP connection whose every operation is bounded by an idle timeout and a cancellation + /// token. When either fires the socket is closed, which unblocks pending I/O on all + /// target frameworks (older frameworks ignore the token once a read has started). + /// + internal sealed class Connection : IDisposable + { + private readonly TcpClient _client; + private readonly TimeSpan _timeout; + private readonly CancellationToken _outerToken; + private readonly CancellationTokenSource _cts; + private readonly CancellationTokenRegistration _registration; + + public Connection(TcpClient client, TimeSpan timeout, CancellationToken cancellationToken) + { + _client = client; + _timeout = timeout; + _outerToken = cancellationToken; + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _registration = _cts.Token.Register(state => ((TcpClient)state!).Dispose(), client); + } + + public TcpClient TcpClient => _client; + + public Stream? Stream { get; set; } + + public CancellationToken Token => _cts.Token; + + public bool TimedOut => _cts.IsCancellationRequested && !_outerToken.IsCancellationRequested; + + /// Starts (or restarts) the idle timer before the next operation. + public void ResetTimer() => _cts.CancelAfter(_timeout); + + public async Task ReadAsync(byte[] buffer, int offset, int count) + { + ResetTimer(); + return await Stream!.ReadAsync(buffer, offset, count, _cts.Token).ConfigureAwait(false); + } + + public async Task ReadExactlyAsync(byte[] buffer, int offset, int count) + { + while (count > 0) + { + int read = await ReadAsync(buffer, offset, count).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("The remote party closed the connection."); + } + + offset += read; + count -= read; + } + } + + public async Task WriteAsync(byte[] buffer, int offset, int count) + { + ResetTimer(); + await Stream!.WriteAsync(buffer, offset, count, _cts.Token).ConfigureAwait(false); + } + + public async Task FlushAsync() + { + ResetTimer(); + await Stream!.FlushAsync(_cts.Token).ConfigureAwait(false); + } + + /// + /// Converts the exception an aborted operation produced into the one callers expect: + /// for cancellation, + /// for the idle timer, and the original exception otherwise. + /// + public Exception Translate(Exception exception) + { + if (exception is EftException) + { + return exception; + } + + if (_outerToken.IsCancellationRequested) + { + return new OperationCanceledException("The transfer was canceled.", exception, _outerToken); + } + + if (TimedOut) + { + return new TimeoutException("The network operation timed out.", exception); + } + + return exception; + } + + public void Dispose() + { + _registration.Dispose(); + _cts.Dispose(); + Stream?.Dispose(); + _client.Dispose(); + } + } +} diff --git a/EasyFileTransfer/Internal/FileNameValidator.cs b/EasyFileTransfer/Internal/FileNameValidator.cs new file mode 100644 index 0000000..f1c7d01 --- /dev/null +++ b/EasyFileTransfer/Internal/FileNameValidator.cs @@ -0,0 +1,59 @@ +using System; + +namespace EasyFileTransfer.Internal +{ + /// + /// Decides whether a file name received from the network is safe to create inside the + /// save directory. The rules are the union of Windows and Unix restrictions so a name + /// accepted on one platform is accepted on all of them. + /// + internal static class FileNameValidator + { + public const int MaxLength = 255; + + private static readonly char[] ForbiddenChars = { '/', '\\', ':', '*', '?', '"', '<', '>', '|' }; + + private static readonly string[] ReservedNames = + { + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³", + }; + + public static bool IsValid(string? name) + { + if (string.IsNullOrWhiteSpace(name) || name!.Length > MaxLength) + { + return false; + } + + // Windows silently strips trailing dots and spaces, which would let "a.txt." alias "a.txt"; + // this also rejects "." and "..". + char last = name[name.Length - 1]; + if (last == '.' || last == ' ') + { + return false; + } + + foreach (char c in name) + { + if (c < 0x20 || c == 0x7F || Array.IndexOf(ForbiddenChars, c) >= 0) + { + return false; + } + } + + int dot = name.IndexOf('.'); + string stem = (dot < 0 ? name : name.Substring(0, dot)).TrimEnd(' '); + foreach (string reserved in ReservedNames) + { + if (string.Equals(stem, reserved, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + } +} diff --git a/EasyFileTransfer/Internal/Protocol.cs b/EasyFileTransfer/Internal/Protocol.cs new file mode 100644 index 0000000..31bfd13 --- /dev/null +++ b/EasyFileTransfer/Internal/Protocol.cs @@ -0,0 +1,110 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace EasyFileTransfer.Internal +{ + /// + /// Wire format of protocol version 2. All integers are big-endian. + /// + /// server -> client hello "EFT" | version:1 | status:1 | flags:1 | nonce:32 + /// client -> server request "EFT" | version:1 | proof:32 | nameLength:2 | name:UTF-8 | fileLength:8 + /// server -> client status:1 + /// client -> server file bytes (fileLength) | sha256:32 + /// server -> client status:1 + /// + /// proof = HMAC-SHA256(key: UTF-8 access token, data: nonce), or zeros when no token is used. + /// + internal static class Protocol + { + public const byte Version = 2; + public const int MagicLength = 3; + public const int NonceLength = 32; + public const int ProofLength = 32; + public const int HashLength = 32; + public const int MaxFileNameBytes = 1024; + public const int BufferSize = 81920; + public const byte FlagAuthRequired = 0x01; + + public const int HelloLength = MagicLength + 1 + 1 + 1 + NonceLength; + public const int RequestHeaderLength = MagicLength + 1 + ProofLength + 2; + + public static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private static readonly byte[] Magic = { (byte)'E', (byte)'F', (byte)'T' }; + + public static void WriteMagic(byte[] buffer, int offset) => Buffer.BlockCopy(Magic, 0, buffer, offset, MagicLength); + + public static bool HasMagic(byte[] buffer, int offset) => + buffer[offset] == Magic[0] && buffer[offset + 1] == Magic[1] && buffer[offset + 2] == Magic[2]; + + public static byte[] CreateNonce() + { + var nonce = new byte[NonceLength]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(nonce); + } + + return nonce; + } + + public static byte[] ComputeProof(string? accessToken, byte[] nonce) + { + if (accessToken is null) + { + return new byte[ProofLength]; + } + + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(accessToken))) + { + return hmac.ComputeHash(nonce); + } + } + + /// Compares two byte arrays in time that does not depend on their contents. + public static bool FixedTimeEquals(byte[] left, int leftOffset, byte[] right, int length) + { + if (right.Length != length || left.Length - leftOffset < length) + { + return false; + } + + int diff = 0; + for (int i = 0; i < length; i++) + { + diff |= left[leftOffset + i] ^ right[i]; + } + + return diff == 0; + } + + public static void WriteUInt16(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 8); + buffer[offset + 1] = (byte)value; + } + + public static int ReadUInt16(byte[] buffer, int offset) => (buffer[offset] << 8) | buffer[offset + 1]; + + public static void WriteInt64(byte[] buffer, int offset, long value) + { + for (int i = 7; i >= 0; i--) + { + buffer[offset + i] = (byte)value; + value >>= 8; + } + } + + public static long ReadInt64(byte[] buffer, int offset) + { + long value = 0; + for (int i = 0; i < 8; i++) + { + value = (value << 8) | buffer[offset + i]; + } + + return value; + } + } +} diff --git a/EasyFileTransfer/Model/Response.cs b/EasyFileTransfer/Model/Response.cs index ccea682..4f912f2 100644 --- a/EasyFileTransfer/Model/Response.cs +++ b/EasyFileTransfer/Model/Response.cs @@ -1,14 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System; namespace EasyFileTransfer.Model { + /// + /// Result of the legacy method. + /// + [Obsolete("Returned only by the legacy EftClient.Send method. SendFileAsync throws EftException on failure instead.")] public class Response { + /// 1 on success, -1 on failure. public int status { get; set; } - public string description { get; set; } + + /// Human-readable result. + public string description { get; set; } = string.Empty; } } diff --git a/README.md b/README.md index 87cf8cd..6d9a03c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,168 @@ # Easy File Transfer -An easy way to transfer file with **any size** on network with tcp protocol. -## usage +[![CI](https://github.com/Jamalianpour/EasyFileTransfer/actions/workflows/ci.yml/badge.svg)](https://github.com/Jamalianpour/EasyFileTransfer/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/EasyFileTransfer)](https://www.nuget.org/packages/EasyFileTransfer) -1. install nuget package: -![Nuget](https://img.shields.io/nuget/v/EasyFileTransfer) -```nuget - Install-Package EasyFileTransfer -Version 0.1.5 +An easy way to transfer files of **any size** over a network using TCP. + +- Streams files of any size with constant memory use +- Async API with progress reporting and cancellation +- SHA-256 integrity check on every transfer +- Optional access-token authentication and TLS encryption +- Hardened server: file-name validation, size limits, connection limits and timeouts +- Targets .NET Standard 2.0, .NET 8 and .NET 10 + +## Install + +```shell +dotnet add package EasyFileTransfer --prerelease ``` -2. import the EasyFileTransfer library: +## Usage + +### Server + ```csharp using EasyFileTransfer; + +var server = new EftServer(new EftServerOptions +{ + SaveDirectory = @"C:\Inbox", + Port = 1300, + AccessToken = "a-long-random-secret", // recommended +}); + +server.FileReceived += (s, e) => Console.WriteLine($"Saved {e.FilePath} ({e.Length} bytes) from {e.RemoteEndPoint}"); +server.TransferFailed += (s, e) => Console.WriteLine($"Rejected {e.RemoteEndPoint}: {e.Exception.Message}"); + +server.Start(); // returns immediately; the server runs in the background +// ... +await server.StopAsync(); ``` -3. for server at first you should start the EftServer: +### Client + ```csharp -EftServer server = new EftServer(saveTo, port); -System.Threading.Thread thread = new System.Threading.Thread(server.StartServer); -thread.Start(); +using EasyFileTransfer; + +var client = new EftClient("192.168.1.10", 1300, new EftClientOptions +{ + AccessToken = "a-long-random-secret", +}); + +var progress = new Progress(p => Console.WriteLine($"{p.Percentage:0}%")); +await client.SendFileAsync(@"C:\big.iso", progress, cancellationToken); ``` -4. for client you can send file with this line: +`SendFileAsync` throws `EftException` (with a `Status` such as `AuthenticationFailed`, `FileTooLarge` or +`IntegrityCheckFailed`) when the server rejects a transfer, `TimeoutException` when the network stalls, +and `IOException` for other network errors. `SendAsync(stream, fileName)` sends any seekable stream. + +### Encrypting with TLS + +Give the server a certificate with a private key and turn on `UseTls` in the client: + ```csharp -EftClient.Send(Path/to/file, serverIP, serverPort); +var server = new EftServer(new EftServerOptions +{ + SaveDirectory = @"C:\Inbox", + AccessToken = token, + ServerCertificate = X509CertificateLoader.LoadPkcs12FromFile("server.pfx", password), +}); + +var client = new EftClient("fileserver.example.com", 1300, new EftClientOptions +{ + AccessToken = token, + UseTls = true, +}); ``` -it's finished. you can enjoy that. -don't forget star this repository. :) +For a self-signed certificate, pin it on the client instead of disabling validation: + +```csharp +const string expectedThumbprint = "3F1A..."; // server certificate's SHA-1 thumbprint + +var options = new EftClientOptions +{ + UseTls = true, + ServerCertificateValidationCallback = (_, cert, _, _) => + cert != null && cert.GetCertHashString() == expectedThumbprint, +}; +``` + +### Server options + +| Option | Default | Description | +| --- | --- | --- | +| `SaveDirectory` | (required) | Where received files are stored. Created if missing. | +| `Port` | `1300` | Port to listen on (`0` = any free port; see `LocalEndPoint`). | +| `BindAddress` | `IPAddress.Any` | Interface to listen on. Use `IPAddress.Loopback` for local-only. | +| `AccessToken` | `null` | Shared secret clients must know. `null` lets anyone upload. | +| `ServerCertificate` | `null` | Enables TLS. | +| `MaxFileSize` | `null` (no limit) | Largest accepted file in bytes. | +| `MaxConcurrentConnections` | `10` | Extra connections are refused with `ServerBusy`. | +| `IdleTimeout` | `30 s` | Connections that stall longer than this are dropped. | + +If a file with the same name already exists, the new file is saved as `name (1).ext`, `name (2).ext` and so on. +Existing files are never overwritten. + +## Security + +Version 0.2 replaces the 0.1.x wire protocol, which had serious security problems: + +- **Path traversal / arbitrary file write.** The 0.1.x server built the destination path by joining + the save directory with the file name the client sent. A name like `..\..\Windows\System32\evil.dll` + or an absolute path wrote anywhere the server process could write. File names are now validated + (no path separators, drive letters, `..`, control characters, alternate data streams or Windows + device names) and the resolved path must be directly inside `SaveDirectory`. +- **Remote crash (denial of service).** Any malformed packet, a duplicate file name, or a client + disconnecting at the wrong moment threw an unhandled exception on a background thread, which + terminates the whole server process. A client that disconnected mid-transfer could also leave + a thread spinning at 100% CPU forever. +- **Unbounded memory allocation.** The length field of each packet was trusted, so one packet could + make the server allocate up to 2 GB. +- **No authentication, encryption or integrity check.** Anyone on the network could upload files, + and data travelled in plain text with no way to detect corruption. +- **No limits.** No timeouts, connection limit or file size limit, and partial uploads were left + on disk. + +Recommendations: + +- Always set `AccessToken` (a long random value) unless the server is only reachable by trusted hosts. + The token is never sent over the network; the client answers an HMAC-SHA256 challenge instead. +- Use TLS on untrusted networks. Without it, file contents can be read, and an attacker who can + modify traffic can tamper with the transfer. +- Set `MaxFileSize` and point `SaveDirectory` at a dedicated folder. + +To report a vulnerability, see [SECURITY.md](SECURITY.md). + +## Upgrading from 0.1.x + +The 0.2 protocol is **not compatible** with 0.1.x. Upgrade the client and the server together. + +The 0.1.x API (`EftClient.Send`, `EftClient.ProgressValue`, `new EftServer(saveTo, port)` and +`StartServer()`) still compiles but is marked `[Obsolete]`. It now runs on the new protocol, so it +gets the same security fixes. Move to the async API when you can: + +| 0.1.x | 0.2 | +| --- | --- | +| `EftClient.Send(path, ip, port)` | `await new EftClient(ip, port).SendFileAsync(path)` | +| `EftClient.ProgressValue` | `IProgress` argument | +| `new EftServer(saveTo, port)` | `new EftServer(new EftServerOptions { SaveDirectory = saveTo, Port = port })` | +| `new Thread(server.StartServer).Start()` | `server.Start()` and `await server.StopAsync()` | +| `Response.status == 1` | No exception thrown | + +## Building + +```shell +dotnet build +dotnet test +``` + +The Windows Forms samples (`Sample.Server`, `Sample.Client`) build on any OS but run only on Windows. ## Screenshots -sample screenshots like this: | Server | Client | | ------------- | ------------- | | | | - - diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ed52dc9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| --- | --- | +| 0.2.x | Yes | +| 0.1.x | No. It allows remote file writes anywhere on the server and remote crashes. Upgrade. | + +## Reporting a vulnerability + +Please do not open a public issue. Report it privately through +[GitHub security advisories](https://github.com/Jamalianpour/EasyFileTransfer/security/advisories/new) +with steps to reproduce. You should get a response within a week. diff --git a/Sample.Client/Form1.Designer.cs b/Sample.Client/Form1.Designer.cs index 6a799e5..e2f1c29 100644 --- a/Sample.Client/Form1.Designer.cs +++ b/Sample.Client/Form1.Designer.cs @@ -35,6 +35,10 @@ private void InitializeComponent() this.label5 = new System.Windows.Forms.Label(); this.textBox2 = new System.Windows.Forms.TextBox(); this.openFileDialog1 = new System.Windows.Forms.OpenFileDialog(); + this.label6 = new System.Windows.Forms.Label(); + this.tokenBox = new System.Windows.Forms.TextBox(); + this.progressBar1 = new System.Windows.Forms.ProgressBar(); + this.statusLabel = new System.Windows.Forms.Label(); this.groupBox2.SuspendLayout(); this.SuspendLayout(); // @@ -45,9 +49,11 @@ private void InitializeComponent() this.groupBox2.Controls.Add(this.label4); this.groupBox2.Controls.Add(this.SnedButton); this.groupBox2.Controls.Add(this.label5); + this.groupBox2.Controls.Add(this.label6); + this.groupBox2.Controls.Add(this.tokenBox); this.groupBox2.Location = new System.Drawing.Point(12, 12); this.groupBox2.Name = "groupBox2"; - this.groupBox2.Size = new System.Drawing.Size(345, 140); + this.groupBox2.Size = new System.Drawing.Size(345, 165); this.groupBox2.TabIndex = 10; this.groupBox2.TabStop = false; this.groupBox2.Text = "send to"; @@ -71,7 +77,7 @@ private void InitializeComponent() // // SnedButton // - this.SnedButton.Location = new System.Drawing.Point(122, 90); + this.SnedButton.Location = new System.Drawing.Point(122, 115); this.SnedButton.Name = "SnedButton"; this.SnedButton.Size = new System.Drawing.Size(108, 38); this.SnedButton.TabIndex = 0; @@ -95,6 +101,38 @@ private void InitializeComponent() this.textBox2.Size = new System.Drawing.Size(256, 20); this.textBox2.TabIndex = 7; // + // label6 + // + this.label6.AutoSize = true; + this.label6.Location = new System.Drawing.Point(18, 89); + this.label6.Name = "label6"; + this.label6.Size = new System.Drawing.Size(39, 13); + this.label6.TabIndex = 8; + this.label6.Text = "token :"; + // + // tokenBox + // + this.tokenBox.Location = new System.Drawing.Point(63, 86); + this.tokenBox.Name = "tokenBox"; + this.tokenBox.Size = new System.Drawing.Size(256, 20); + this.tokenBox.TabIndex = 9; + this.tokenBox.UseSystemPasswordChar = true; + // + // progressBar1 + // + this.progressBar1.Location = new System.Drawing.Point(12, 183); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(345, 20); + this.progressBar1.TabIndex = 11; + // + // statusLabel + // + this.statusLabel.AutoSize = true; + this.statusLabel.Location = new System.Drawing.Point(12, 210); + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(0, 13); + this.statusLabel.TabIndex = 12; + // // openFileDialog1 // this.openFileDialog1.FileName = "openFileDialog1"; @@ -103,14 +141,17 @@ private void InitializeComponent() // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(369, 162); + this.ClientSize = new System.Drawing.Size(369, 235); this.Controls.Add(this.groupBox2); + this.Controls.Add(this.progressBar1); + this.Controls.Add(this.statusLabel); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; this.Name = "Form1"; this.Text = "Client"; this.groupBox2.ResumeLayout(false); this.groupBox2.PerformLayout(); this.ResumeLayout(false); + this.PerformLayout(); } @@ -123,6 +164,10 @@ private void InitializeComponent() private System.Windows.Forms.Label label5; private System.Windows.Forms.TextBox textBox2; private System.Windows.Forms.OpenFileDialog openFileDialog1; + private System.Windows.Forms.Label label6; + private System.Windows.Forms.TextBox tokenBox; + private System.Windows.Forms.ProgressBar progressBar1; + private System.Windows.Forms.Label statusLabel; } } diff --git a/Sample.Client/Form1.cs b/Sample.Client/Form1.cs index 4c611cf..999cb58 100644 --- a/Sample.Client/Form1.cs +++ b/Sample.Client/Form1.cs @@ -1,12 +1,5 @@ -using EasyFileTransfer; +using EasyFileTransfer; using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows.Forms; namespace Sample.Client @@ -18,15 +11,42 @@ public Form1() InitializeComponent(); } - private void SnedButton_Click(object sender, EventArgs e) + private async void SnedButton_Click(object sender, EventArgs e) { - if (openFileDialog1.ShowDialog() == System.Windows.Forms.DialogResult.OK) + if (openFileDialog1.ShowDialog() != DialogResult.OK) { - EasyFileTransfer.Model.Response rsp = EftClient.Send(openFileDialog1.FileName, textBox2.Text, Convert.ToInt32(textBox1.Text)); - if (rsp.status == 1) + return; + } + + if (!int.TryParse(textBox1.Text, out int port)) + { + MessageBox.Show("Port must be a number.", "", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + SnedButton.Enabled = false; + statusLabel.Text = "sending..."; + try + { + var client = new EftClient(textBox2.Text.Trim(), port, new EftClientOptions { - MessageBox.Show("send successfully.", "", MessageBoxButtons.OK, MessageBoxIcon.Information); - } + AccessToken = string.IsNullOrEmpty(tokenBox.Text) ? null : tokenBox.Text, + }); + + // Progress raises its callback on the UI thread. + var progress = new Progress(p => progressBar1.Value = (int)p.Percentage); + await client.SendFileAsync(openFileDialog1.FileName, progress); + + statusLabel.Text = "send successfully."; + } + catch (Exception ex) + { + statusLabel.Text = "failed."; + MessageBox.Show(ex.Message, "Send failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SnedButton.Enabled = true; } } } diff --git a/Sample.Client/Program.cs b/Sample.Client/Program.cs index 9bdedb5..1941203 100644 --- a/Sample.Client/Program.cs +++ b/Sample.Client/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System; using System.Windows.Forms; namespace Sample.Client @@ -14,8 +11,7 @@ static class Program [STAThread] static void Main() { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); + ApplicationConfiguration.Initialize(); Application.Run(new Form1()); } } diff --git a/Sample.Client/Sample.Client.csproj b/Sample.Client/Sample.Client.csproj index 3309c04..99146db 100644 --- a/Sample.Client/Sample.Client.csproj +++ b/Sample.Client/Sample.Client.csproj @@ -1,91 +1,17 @@ - - - + + - Debug - AnyCPU - {2638D1E6-BBBC-496E-B3B8-022B0F57A242} WinExe - Sample.Client - Sample.Client - v4.8 - 512 - true - true - + net10.0-windows + true + disable + false + + true - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - Form1.cs - - - - - Form1.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - + - - {095B5958-8DA4-4098-A485-935D7271431D} - EasyFileTransfer - + - - \ No newline at end of file + + diff --git a/Sample.Server/Form1.Designer.cs b/Sample.Server/Form1.Designer.cs index 3817b04..2747d46 100644 --- a/Sample.Server/Form1.Designer.cs +++ b/Sample.Server/Form1.Designer.cs @@ -40,6 +40,9 @@ private void InitializeComponent() this.label4 = new System.Windows.Forms.Label(); this.status = new System.Windows.Forms.Label(); this.folderBrowserDialog1 = new System.Windows.Forms.FolderBrowserDialog(); + this.label6 = new System.Windows.Forms.Label(); + this.tokenBox = new System.Windows.Forms.TextBox(); + this.lastFile = new System.Windows.Forms.Label(); this.groupBox1.SuspendLayout(); this.SuspendLayout(); // @@ -56,7 +59,7 @@ private void InitializeComponent() // label3 // this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(45, 135); + this.label3.Location = new System.Drawing.Point(45, 166); this.label3.Name = "label3"; this.label3.Size = new System.Drawing.Size(40, 13); this.label3.TabIndex = 13; @@ -70,9 +73,11 @@ private void InitializeComponent() this.groupBox1.Controls.Add(this.saveTo); this.groupBox1.Controls.Add(this.Port); this.groupBox1.Controls.Add(this.StartButton); + this.groupBox1.Controls.Add(this.label6); + this.groupBox1.Controls.Add(this.tokenBox); this.groupBox1.Location = new System.Drawing.Point(7, 3); this.groupBox1.Name = "groupBox1"; - this.groupBox1.Size = new System.Drawing.Size(345, 124); + this.groupBox1.Size = new System.Drawing.Size(345, 155); this.groupBox1.TabIndex = 11; this.groupBox1.TabStop = false; // @@ -123,7 +128,7 @@ private void InitializeComponent() // label5 // this.label5.AutoSize = true; - this.label5.Location = new System.Drawing.Point(4, 133); + this.label5.Location = new System.Drawing.Point(4, 164); this.label5.Name = "label5"; this.label5.Size = new System.Drawing.Size(44, 13); this.label5.TabIndex = 14; @@ -132,7 +137,7 @@ private void InitializeComponent() // label4 // this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(285, 135); + this.label4.Location = new System.Drawing.Point(285, 166); this.label4.Name = "label4"; this.label4.Size = new System.Drawing.Size(41, 13); this.label4.TabIndex = 12; @@ -142,28 +147,53 @@ private void InitializeComponent() // this.status.AutoSize = true; this.status.ForeColor = System.Drawing.Color.Red; - this.status.Location = new System.Drawing.Point(324, 135); + this.status.Location = new System.Drawing.Point(324, 166); this.status.Name = "status"; this.status.RightToLeft = System.Windows.Forms.RightToLeft.Yes; this.status.Size = new System.Drawing.Size(21, 13); this.status.TabIndex = 10; this.status.Text = "Off"; // + // label6 + // + this.label6.AutoSize = true; + this.label6.Location = new System.Drawing.Point(20, 126); + this.label6.Name = "label6"; + this.label6.Size = new System.Drawing.Size(39, 13); + this.label6.TabIndex = 7; + this.label6.Text = "token :"; + // + // tokenBox + // + this.tokenBox.Location = new System.Drawing.Point(65, 123); + this.tokenBox.Name = "tokenBox"; + this.tokenBox.Size = new System.Drawing.Size(230, 20); + this.tokenBox.TabIndex = 8; + this.tokenBox.UseSystemPasswordChar = true; + // + // lastFile + // + this.lastFile.AutoSize = true; + this.lastFile.Location = new System.Drawing.Point(4, 188); + this.lastFile.Name = "lastFile"; + this.lastFile.Size = new System.Drawing.Size(0, 13); + this.lastFile.TabIndex = 15; + // // Form1 // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(361, 154); + this.ClientSize = new System.Drawing.Size(361, 210); this.Controls.Add(this.label3); this.Controls.Add(this.groupBox1); this.Controls.Add(this.label5); this.Controls.Add(this.label4); this.Controls.Add(this.status); + this.Controls.Add(this.lastFile); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; this.Name = "Form1"; this.Text = "Server"; - this.Load += new System.EventHandler(this.Form1_Load); - this.Leave += new System.EventHandler(this.Form1_Leave); + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); this.groupBox1.ResumeLayout(false); this.groupBox1.PerformLayout(); this.ResumeLayout(false); @@ -185,6 +215,9 @@ private void InitializeComponent() private System.Windows.Forms.Label label4; private System.Windows.Forms.Label status; private System.Windows.Forms.FolderBrowserDialog folderBrowserDialog1; + private System.Windows.Forms.Label label6; + private System.Windows.Forms.TextBox tokenBox; + private System.Windows.Forms.Label lastFile; } } diff --git a/Sample.Server/Form1.cs b/Sample.Server/Form1.cs index 257d572..7ef1d8f 100644 --- a/Sample.Server/Form1.cs +++ b/Sample.Server/Form1.cs @@ -1,19 +1,22 @@ -using System; +using EasyFileTransfer; +using System; using System.Drawing; -using System.Windows.Forms; +using System.IO; using System.Net; using System.Net.Sockets; -using EasyFileTransfer; -using System.IO; +using System.Windows.Forms; namespace Sample.Server { public partial class Form1 : Form { + private EftServer server; + public Form1() { InitializeComponent(); label3.Text = GetLocalIPAddress(); + saveTo.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); } public static string GetLocalIPAddress() @@ -26,41 +29,59 @@ public static string GetLocalIPAddress() return ip.ToString(); } } - throw new Exception("No network adapters with an IPv4 address in the system!"); + + return "unknown"; } - private void StartButton_Click(object sender, EventArgs e) + private async void StartButton_Click(object sender, EventArgs e) { + if (server != null) + { + await server.StopAsync(); + server = null; + SetRunning(false); + return; + } + try { - EftServer server = new EftServer(saveTo.Text, Convert.ToInt32(Port.Text)); - System.Threading.Thread obj_thread = new System.Threading.Thread(server.StartServer); - obj_thread.Start(); - status.ForeColor = Color.Green; - status.Text = "Online"; + server = new EftServer(new EftServerOptions + { + SaveDirectory = saveTo.Text, + Port = Convert.ToInt32(Port.Text), + AccessToken = string.IsNullOrEmpty(tokenBox.Text) ? null : tokenBox.Text, + }); + server.FileReceived += (s, args) => BeginInvoke(new Action(() => lastFile.Text = "received: " + Path.GetFileName(args.FilePath))); + server.TransferFailed += (s, args) => BeginInvoke(new Action(() => lastFile.Text = "failed: " + args.Exception.Message)); + server.Start(); + SetRunning(true); } catch (Exception ex) { + server = null; MessageBox.Show(ex.Message); } } + private void SetRunning(bool running) + { + status.ForeColor = running ? Color.Green : Color.Red; + status.Text = running ? "Online" : "Off"; + StartButton.Text = running ? "Stop Server" : "Start Server"; + saveTo.Enabled = Port.Enabled = tokenBox.Enabled = button1.Enabled = !running; + } + private void button1_Click(object sender, EventArgs e) { if (folderBrowserDialog1.ShowDialog() == DialogResult.OK) { - saveTo.Text = folderBrowserDialog1.SelectedPath + @"\"; + saveTo.Text = folderBrowserDialog1.SelectedPath; } } - private void Form1_Load(object sender, EventArgs e) - { - } - - private void Form1_Leave(object sender, EventArgs e) + private void Form1_FormClosing(object sender, FormClosingEventArgs e) { - this.Dispose(); - Application.Exit(); + server?.Dispose(); } } } diff --git a/Sample.Server/Program.cs b/Sample.Server/Program.cs index 683999e..55bae92 100644 --- a/Sample.Server/Program.cs +++ b/Sample.Server/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System; using System.Windows.Forms; namespace Sample.Server @@ -14,8 +11,7 @@ static class Program [STAThread] static void Main() { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); + ApplicationConfiguration.Initialize(); Application.Run(new Form1()); } } diff --git a/Sample.Server/Sample.Server.csproj b/Sample.Server/Sample.Server.csproj index 61e9cf6..99146db 100644 --- a/Sample.Server/Sample.Server.csproj +++ b/Sample.Server/Sample.Server.csproj @@ -1,91 +1,17 @@ - - - + + - Debug - AnyCPU - {B1F70903-1BA5-4D9A-B0BC-BC66A3F1ED91} WinExe - Sample.Server - Sample.Server - v4.8 - 512 - true - true - + net10.0-windows + true + disable + false + + true - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - Form1.cs - - - - - Form1.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - + - - {095B5958-8DA4-4098-A485-935D7271431D} - EasyFileTransfer - + - - \ No newline at end of file + +