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
+[](https://github.com/Jamalianpour/EasyFileTransfer/actions/workflows/ci.yml)
+[](https://www.nuget.org/packages/EasyFileTransfer)
-1. install nuget package:
-
-```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
+
+