- SlugGuard.IsValid() rejects slugs with path traversal (../, //, uppercase) - SlugGuard.ToSafePath() ensures sync writes stay within target directory - PushCommand: skip files with invalid slugs before upload - SyncCommand: validate server-returned slugs before writing to disk - EditCommand/NewCommand: try/finally guarantees temp file deletion on crash - NotesCache: set 600 permissions on cache file (metadata only, no token) - NewCommand: remove dead Sha256 method (HashHelper already covers this)
146 lines
5.1 KiB
C#
146 lines
5.1 KiB
C#
using NozCli.Core.Client;
|
|
using NozCli.Core.Models;
|
|
using NozCli.Repl;
|
|
using NozCli.Security;
|
|
using Spectre.Console;
|
|
|
|
namespace NozCli.Commands;
|
|
|
|
/// sync <local-path> — pulls notes from the server into a local directory.
|
|
/// Only downloads notes whose server hash differs from the local file hash.
|
|
/// Uses Parallel.ForEachAsync (8 concurrent) for fast bulk downloads.
|
|
public class SyncCommand : ICommand
|
|
{
|
|
public string Name => "sync";
|
|
public string Description => "Pull notes from server to a local directory (hash-diffed)";
|
|
public string Usage => "sync <local-path> [--all] [--dry-run]";
|
|
|
|
private const int Concurrency = 8;
|
|
|
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
|
{
|
|
var localPath = args.FirstOrDefault(a => !a.StartsWith("--"));
|
|
var force = args.Contains("--all");
|
|
var dryRun = args.Contains("--dry-run");
|
|
|
|
if (localPath is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Usage: sync <local-path> [--all] [--dry-run][/]");
|
|
return;
|
|
}
|
|
|
|
localPath = Path.GetFullPath(localPath);
|
|
|
|
// Determine which notes to sync (respect current topic/subpath)
|
|
var candidates = ctx.CurrentTopic is null
|
|
? ctx.NoteCache
|
|
: ctx.NotesInView().ToList();
|
|
|
|
if (candidates is IEnumerable<NoteInfo> enumerable)
|
|
candidates = enumerable.ToList();
|
|
|
|
var noteList = (List<NoteInfo>)candidates;
|
|
|
|
if (noteList.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No notes in current view.[/]");
|
|
return;
|
|
}
|
|
|
|
// Diff: which notes actually need downloading?
|
|
var toDownload = new List<NoteInfo>();
|
|
|
|
foreach (var note in noteList)
|
|
{
|
|
if (!SlugGuard.IsValid(note.Slug))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped unsafe slug from server:[/] {note.Slug}");
|
|
continue;
|
|
}
|
|
|
|
var filePath = SlugGuard.ToSafePath(localPath, note.Slug);
|
|
|
|
if (!force && note.Hash is not null && File.Exists(filePath))
|
|
{
|
|
var localContent = await File.ReadAllTextAsync(filePath, ct);
|
|
var localHash = HashHelper.Sha256(localContent);
|
|
if (localHash == note.Hash) continue;
|
|
}
|
|
|
|
toDownload.Add(note);
|
|
}
|
|
|
|
var unchanged = noteList.Count - toDownload.Count;
|
|
|
|
if (toDownload.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]✓ All {noteList.Count} notes up to date.[/]");
|
|
return;
|
|
}
|
|
|
|
if (dryRun)
|
|
{
|
|
AnsiConsole.MarkupLine($"[dim]Dry run — would download {toDownload.Count} notes, {unchanged} unchanged.[/]");
|
|
foreach (var n in toDownload)
|
|
AnsiConsole.MarkupLine($" [dim]↓[/] {n.Slug}");
|
|
return;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[dim]Downloading {toDownload.Count} notes · {unchanged} unchanged · concurrency {Concurrency}[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
int downloaded = 0, failed = 0;
|
|
var failedSlugs = new System.Collections.Concurrent.ConcurrentBag<string>();
|
|
var lockObj = new object();
|
|
|
|
await AnsiConsole.Progress()
|
|
.Columns(
|
|
new TaskDescriptionColumn(),
|
|
new ProgressBarColumn(),
|
|
new PercentageColumn(),
|
|
new SpinnerColumn())
|
|
.StartAsync(async progress =>
|
|
{
|
|
var task = progress.AddTask($"[cyan]↓ syncing[/]", maxValue: toDownload.Count);
|
|
|
|
await Parallel.ForEachAsync(
|
|
toDownload,
|
|
new ParallelOptions { MaxDegreeOfParallelism = Concurrency, CancellationToken = ct },
|
|
async (note, innerCt) =>
|
|
{
|
|
try
|
|
{
|
|
var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt);
|
|
var filePath = SlugGuard.ToSafePath(localPath, note.Slug)!;
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
|
await File.WriteAllTextAsync(filePath, content.Markdown, innerCt);
|
|
|
|
lock (lockObj) downloaded++;
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
failedSlugs.Add(note.Slug);
|
|
lock (lockObj) failed++;
|
|
}
|
|
finally
|
|
{
|
|
task.Increment(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine(
|
|
$"[green]↓ {downloaded} downloaded[/] " +
|
|
$"[dim]{unchanged} unchanged[/]" +
|
|
(failed > 0 ? $" [red]{failed} failed[/]" : ""));
|
|
|
|
if (failed > 0)
|
|
{
|
|
foreach (var slug in failedSlugs.Order())
|
|
AnsiConsole.MarkupLine($" [red]✗[/] {slug}");
|
|
}
|
|
}
|
|
|
|
}
|