using IceImportDAL.DTO.RemoteFileSources; using Microsoft.Extensions.Logging; using Renci.SshNet; namespace IceImportDAL.CustomCode.RemoteFileSources; // ConnectionType=1 (SFTP). Uses Renci.SshNet's native async methods (ListDirectoryAsync/ // DownloadFileAsync — added directly to SftpClient in SSH.NET 2025.1.0/2026.0.0, which also // fixed GHSA-q939-rpr3-3284 and GHSA-72p8-v4hg-v45p present at the previously-pinned 2016.1.0). // Both require an explicit CancellationToken (no optional-parameter overload). The separate // Renci.SshNet.Async compatibility shim this used to depend on is no longer referenced. // Connect/Disconnect/DeleteFile/RenameFile remain plain synchronous calls on the client — each is // a quick metadata-only round trip, not a bulk-data transfer. // // Every public method here opens its own SftpClient and disconnects/disposes it before returning // (via `using` + try/finally) — same "never leak a socket past the call that opened it" discipline // as FtpRemoteFileSource, applied to CLAUDE.md's IDbConnection/HttpClient rule. public class SftpRemoteFileSource : IRemoteFileSource { private readonly ILogger _logger; public SftpRemoteFileSource(ILogger logger) => _logger = logger; public byte ConnectionType => 1; public async Task> ListFilesAsync(RemoteFileSourceConfigDTO config, CancellationToken ct) { var (host, port) = ParseHost(config.Host); using var client = new SftpClient(host, port, config.Username, config.Credential); try { client.Connect(); ct.ThrowIfCancellationRequested(); var results = new List(); await foreach (var item in client.ListDirectoryAsync(config.RemoteFolderPath, ct).ConfigureAwait(false)) { if (item.IsDirectory) continue; if (item.Name is "." or "..") continue; if (!WildcardPatternMatcher.IsMatch(item.Name, config.FileNamePattern)) continue; results.Add(new RemoteFileInfoDTO { FileName = item.Name, Size = item.Length, LastModifiedUtc = item.LastWriteTimeUtc, RemoteFullPath = item.FullName }); } return results; } finally { if (client.IsConnected) client.Disconnect(); } } public async Task DownloadAsync(RemoteFileSourceConfigDTO config, RemoteFileInfoDTO file, CancellationToken ct) { var (host, port) = ParseHost(config.Host); using var client = new SftpClient(host, port, config.Username, config.Credential); try { client.Connect(); ct.ThrowIfCancellationRequested(); var ms = new MemoryStream(); await client.DownloadFileAsync(file.RemoteFullPath, ms, ct).ConfigureAwait(false); ms.Position = 0; return ms; } finally { if (client.IsConnected) client.Disconnect(); } } public Task PostProcessAsync(RemoteFileSourceConfigDTO config, RemoteFileInfoDTO file, CancellationToken ct) { if (config.PostProcessAction == 0) return Task.CompletedTask; // None var (host, port) = ParseHost(config.Host); using var client = new SftpClient(host, port, config.Username, config.Credential); try { client.Connect(); ct.ThrowIfCancellationRequested(); if (config.PostProcessAction == 1) // Delete { client.DeleteFile(file.RemoteFullPath); } else if (config.PostProcessAction == 2) // Archive { if (string.IsNullOrWhiteSpace(config.ArchiveFolderPath)) { _logger.LogWarning( "IceImport SFTP PostProcess: IceMapId {IceMapId} has PostProcessAction=Archive but no ArchiveFolderPath configured — leaving {File} in place.", config.IceMapId, file.FileName); return Task.CompletedTask; } var destination = CombineRemotePath(config.ArchiveFolderPath, file.FileName); client.RenameFile(file.RemoteFullPath, destination); } return Task.CompletedTask; } finally { if (client.IsConnected) client.Disconnect(); } } private static (string Host, int Port) ParseHost(string hostValue) { // HostVaultRef resolves to either "hostname" (default port 22) or "hostname:port". var parts = hostValue.Split(':', 2); if (parts.Length == 2 && int.TryParse(parts[1], out var port)) return (parts[0], port); return (hostValue, 22); } private static string CombineRemotePath(string folder, string fileName) => folder.EndsWith('/') ? $"{folder}{fileName}" : $"{folder}/{fileName}"; }