noz-cli/NozCli.Core/Security/SlugGuard.cs
kryptomrx 0eebffe745 test: xUnit test suite and Forgejo CI pipeline
- 43 tests across SlugGuard, ReplContext navigation, NotesCache.TimeAgo
- SlugGuard moved to NozCli.Core.Security (shared between REPL and tests)
- Bug fix: FoldersInView() now returns only actual sub-folders, not note segments
  (previously cd tab-completion suggested note names as navigable folders)
- .forgejo/workflows/ci.yml: build + test on every push/PR
2026-05-16 23:53:40 +02:00

34 lines
1.4 KiB
C#

using System.Text.RegularExpressions;
namespace NozCli.Core.Security;
/// Validates slugs before they are sent to the server or written to disk.
public static partial class SlugGuard
{
// Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
// Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
private static partial Regex ValidSlugRegex();
/// Returns true if the slug is safe to use (no path traversal, valid chars).
public static bool IsValid(string slug) =>
!string.IsNullOrWhiteSpace(slug) &&
!slug.Contains("..") &&
!slug.Contains("//") &&
ValidSlugRegex().IsMatch(slug);
/// Resolves a slug to a file path inside baseDir.
/// Returns null if the resulting path would escape baseDir (path traversal).
public static string? ToSafePath(string baseDir, string slug, string extension = ".md")
{
var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension;
var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel));
var baseFull = Path.GetFullPath(baseDir);
// Ensure the resolved path stays strictly inside baseDir
return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)
? fullPath
: null;
}
}