A file sync job
Keep a directory on a server in step with a local one, as rsync -t would for the common case: upload new and changed files,
optionally delete files that no longer exist locally, and make every replacement atomic. The job is safe to run again after any
failure: files already in place are skipped.
The approach
- Compare by size and modification time. One recursive listing of the remote tree gives both, with no request per file.
- Preserve times on upload, so a file uploaded once compares as unchanged next time.
- Upload beside, then rename over the target (
posix-rename@openssh.com), so readers never see a half-written file. - Retry with a new connection on a dropped connection; the rerun skips what is done.
The code
public sealed record SyncResult(int Uploaded, int Unchanged, int Deleted);
/// <summary>
/// Makes a remote directory match a local one: uploads new and changed files (by size and modification time), and with
/// <paramref name="delete"/> removes remote files that no longer exist locally. Safe to run again after a failure.
/// </summary>
public static async Task<SyncResult> MirrorAsync(SftpClient sftp, string localRoot, string remoteRoot, bool delete,
CancellationToken cancellationToken)
{
var remote = new Dictionary<string, SftpFileAttributes>(StringComparer.Ordinal);
await ListRecursiveAsync(sftp, remoteRoot, "", remote, cancellationToken);
int uploaded = 0, unchanged = 0, deleted = 0;
var options = new SftpTransferOptions { Preserve = true }; // keeps the modification time, so the next run sees "unchanged"
foreach (var path in Directory.EnumerateFiles(localRoot, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(localRoot, path).Replace('\\', '/');
var local = new FileInfo(path);
if (remote.Remove(relative, out var existing)
&& existing.Size == local.Length
&& existing.LastWriteTime is { } written
&& written.ToUnixTimeSeconds() == new DateTimeOffset(local.LastWriteTimeUtc).ToUnixTimeSeconds()) // SFTP v3 keeps whole seconds
{
unchanged++;
continue;
}
var target = $"{remoteRoot}/{relative}";
await EnsureDirectoryAsync(sftp, target[..target.LastIndexOf('/')], cancellationToken);
// Upload beside the target, then rename over it: readers see the old file or the new one, never half of one.
var temporary = $"{target}.sync-tmp";
await sftp.UploadFileAsync(path, temporary, options, cancellationToken);
await sftp.RenameAsync(temporary, target, overwrite: true, cancellationToken);
uploaded++;
}
if (delete)
{
foreach (var extra in remote.Keys) // what is left was not found locally
{
await sftp.DeleteFileAsync($"{remoteRoot}/{extra}", cancellationToken);
deleted++;
}
}
return new SyncResult(uploaded, unchanged, deleted);
}
private static async Task ListRecursiveAsync(SftpClient sftp, string root, string prefix, Dictionary<string, SftpFileAttributes> files,
CancellationToken cancellationToken)
{
var directory = prefix.Length == 0 ? root : $"{root}/{prefix}";
if (!await sftp.ExistsAsync(directory, cancellationToken))
{
return;
}
await foreach (var entry in sftp.ListDirectoryAsync(directory, cancellationToken))
{
var relative = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}";
if (entry.Attributes.IsDirectory)
{
await ListRecursiveAsync(sftp, root, relative, files, cancellationToken);
}
else if (entry.Attributes.IsRegularFile && !entry.Name.EndsWith(".sync-tmp", StringComparison.Ordinal))
{
files[relative] = entry.Attributes;
}
}
}
private static async Task EnsureDirectoryAsync(SftpClient sftp, string directory, CancellationToken cancellationToken)
{
if (directory.Length == 0 || await sftp.ExistsAsync(directory, cancellationToken))
{
return;
}
var slash = directory.LastIndexOf('/');
if (slash > 0)
{
await EnsureDirectoryAsync(sftp, directory[..slash], cancellationToken);
}
await sftp.CreateDirectoryAsync(directory, cancellationToken: cancellationToken);
}
// sync <alias> <local directory> <remote directory>: retried on a dropped connection; each run skips what is done.
for (var attempt = 1; ; attempt++)
{
try
{
await using var client = await SshClient.ConnectAsync(args[0]);
await using var sftp = await client.OpenSftpAsync();
var result = await MirrorAsync(sftp, args[1], args[2], delete: false, CancellationToken.None);
Console.WriteLine($"{result.Uploaded} uploaded, {result.Unchanged} unchanged, {result.Deleted} deleted");
return 0;
}
catch (SshException ex) when (attempt < 3 && ex.Reason is SshFailureReason.ConnectionClosed or SshFailureReason.ChannelClosed
or SshFailureReason.Timeout or SshFailureReason.ConnectFailed)
{
Console.Error.WriteLine($"Attempt {attempt} failed ({ex.Reason}); trying again.");
await Task.Delay(TimeSpan.FromSeconds(10 * attempt));
}
}
For production
- Large files on flaky links. Upload large files with
Resume = trueto a fixed temporary name (not a new one per attempt), so a retry continues where it stopped. - Content changes with the same size and time are not detected. When that matters, compare checksums: run
sha256sumon the server for candidate files, in one command. - Clock and time zones do not matter: times travel as UTC seconds. Whole seconds only, which the comparison accounts for.
- Deletion is off by default in the example's
MainAsync. Turn it on only when the remote directory belongs to this job. - Downloads. The mirror image (server to local) is the same with
DownloadFileAsync; for a plain copy of a tree, useDownloadDirectoryAsync, which already skips symbolic links and unsafe names.