noz-cli/NozCli/Commands/PushCommand.cs
kryptomrx b971d45e78 security: slug validation, path traversal protection, temp file cleanup
- 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)
2026-05-16 23:36:40 +02:00

95 lines
3.3 KiB
C#

using NozCli.Repl;
using NozCli.Security;
using Spectre.Console;
namespace NozCli.Commands;
/// push <local-path> — reads .md files from a local directory and uploads changed ones.
public class PushCommand : ICommand
{
public string Name => "push";
public string Description => "Push local markdown files to the server";
public string Usage => "push <local-path> [--all]";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var localPath = args.FirstOrDefault(a => !a.StartsWith("--"));
var force = args.Contains("--all");
if (localPath is null)
{
AnsiConsole.MarkupLine("[red]Usage: push <local-path> [--all][/]");
return;
}
localPath = Path.GetFullPath(localPath);
if (!Directory.Exists(localPath))
{
AnsiConsole.MarkupLine($"[red]Directory not found: {localPath}[/]");
return;
}
var files = Directory.EnumerateFiles(localPath, "*.md", SearchOption.AllDirectories)
.Where(f => !Path.GetFileName(f).StartsWith("_"))
.ToList();
if (files.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No markdown files found.[/]");
return;
}
// Build slug → server hash map for incremental push
var serverHashes = ctx.NoteCache.ToDictionary(n => n.Slug, n => n.Hash ?? "");
int pushed = 0, skipped = 0, failed = 0;
await AnsiConsole.Progress()
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new SpinnerColumn())
.StartAsync(async progress =>
{
var task = progress.AddTask("Pushing notes…", maxValue: files.Count);
foreach (var file in files)
{
var relative = Path.GetRelativePath(localPath, file);
var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", "");
if (!SlugGuard.IsValid(slug))
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped invalid slug:[/] {slug}");
task.Increment(1);
continue;
}
var markdown = await File.ReadAllTextAsync(file);
var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown);
if (!force && serverHashes.TryGetValue(slug, out var serverHash) && serverHash == localHash)
{
skipped++;
task.Increment(1);
continue;
}
try
{
await ctx.Api.PutNoteAsync(slug, markdown, ct);
pushed++;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]✗ {slug}: {ex.Message}[/]");
failed++;
}
task.Increment(1);
}
});
AnsiConsole.MarkupLine($"[green]↑ {pushed} pushed[/] [dim]{skipped} unchanged {(failed > 0 ? $"[red]{failed} failed[/]" : "")}[/]");
// Refresh note cache
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
}
}