diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..ad284a7
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.x'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore -c Release
+
+ - name: Test
+ run: dotnet test NozCli.Tests/NozCli.Tests.csproj --no-build -c Release --verbosity normal
diff --git a/NozCli.Core/Security/SlugGuard.cs b/NozCli.Core/Security/SlugGuard.cs
new file mode 100644
index 0000000..5aa3c7a
--- /dev/null
+++ b/NozCli.Core/Security/SlugGuard.cs
@@ -0,0 +1,34 @@
+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;
+ }
+}
diff --git a/NozCli.Tests/NotesCacheTests.cs b/NozCli.Tests/NotesCacheTests.cs
new file mode 100644
index 0000000..f5dcaef
--- /dev/null
+++ b/NozCli.Tests/NotesCacheTests.cs
@@ -0,0 +1,23 @@
+using Xunit;
+using NozCli.Repl;
+
+namespace NozCli.Tests;
+
+public class NotesCacheTests
+{
+ [Theory]
+ [InlineData(10, "gerade eben")]
+ [InlineData(59, "gerade eben")]
+ [InlineData(60, "vor 1 Min.")]
+ [InlineData(300, "vor 5 Min.")]
+ [InlineData(3599, "vor 59 Min.")]
+ [InlineData(3600, "vor 1 Std.")]
+ [InlineData(7200, "vor 2 Std.")]
+ [InlineData(86400, "vor 1 Tagen")]
+ public void TimeAgo_ReturnsCorrectLabel(int secondsAgo, string expected)
+ {
+ var dt = DateTimeOffset.UtcNow.AddSeconds(-secondsAgo);
+ var result = NotesCache.TimeAgo(dt);
+ Assert.Equal(expected, result);
+ }
+}
diff --git a/NozCli.Tests/NozCli.Tests.csproj b/NozCli.Tests/NozCli.Tests.csproj
new file mode 100644
index 0000000..01bfa29
--- /dev/null
+++ b/NozCli.Tests/NozCli.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+ false
+
+
+
diff --git a/NozCli.Tests/ReplContextTests.cs b/NozCli.Tests/ReplContextTests.cs
new file mode 100644
index 0000000..e798245
--- /dev/null
+++ b/NozCli.Tests/ReplContextTests.cs
@@ -0,0 +1,165 @@
+using Xunit;
+using NozCli.Core.Config;
+using NozCli.Core.Models;
+using NozCli.Repl;
+
+namespace NozCli.Tests;
+
+public class ReplContextTests
+{
+ // Builds a ReplContext with a fixed NoteCache — no API calls made.
+ private static ReplContext BuildCtx(params NoteInfo[] notes)
+ {
+ var cfg = new LocalConfig { ServerUrl = "http://localhost:9999", Token = "test" };
+ var ctx = new ReplContext(null!, cfg); // Api is null — not used in navigation
+ ctx.NoteCache = [.. notes];
+ return ctx;
+ }
+
+ private static NoteInfo Note(string slug, string topic, string status = "seed") =>
+ new(slug, $"Title {slug}", status, topic, "de", false, null);
+
+ // ── CurrentPath ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void CurrentPath_AtRoot_IsWave()
+ {
+ var ctx = BuildCtx();
+ Assert.Equal("~", ctx.CurrentPath);
+ }
+
+ [Fact]
+ public void CurrentPath_InTopic_ShowsTopic()
+ {
+ var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ Assert.Equal("~/psychologie", ctx.CurrentPath);
+ }
+
+ [Fact]
+ public void CurrentPath_InSubfolder_ShowsFullPath()
+ {
+ var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ ctx.NavigateTo("sub");
+ Assert.Equal("~/psychologie/sub", ctx.CurrentPath);
+ }
+
+ // ── FoldersInView ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void FoldersInView_AtRoot_ReturnsDistinctTopics()
+ {
+ var ctx = BuildCtx(
+ Note("psychologie/a", "psychologie"),
+ Note("psychologie/b", "psychologie"),
+ Note("biosysteme/c", "biosysteme"));
+
+ var folders = ctx.FoldersInView().OrderBy(f => f).ToList();
+ Assert.Equal(["biosysteme", "psychologie"], folders);
+ }
+
+ [Fact]
+ public void FoldersInView_InTopic_ReturnsSubfolders()
+ {
+ var ctx = BuildCtx(
+ Note("psychologie/sub/note", "psychologie"),
+ Note("psychologie/flat-note", "psychologie"));
+
+ ctx.NavigateTo("psychologie");
+ var folders = ctx.FoldersInView().ToList();
+
+ Assert.Contains("sub", folders);
+ Assert.DoesNotContain("flat-note", folders);
+ }
+
+ // ── NavigateTo / NavigateUp ──────────────────────────────────────────────
+
+ [Fact]
+ public void NavigateTo_ValidTopic_ReturnsTrue()
+ {
+ var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
+ Assert.True(ctx.NavigateTo("psychologie"));
+ Assert.Equal("psychologie", ctx.CurrentTopic);
+ }
+
+ [Fact]
+ public void NavigateTo_UnknownTopic_ReturnsFalse()
+ {
+ var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
+ Assert.False(ctx.NavigateTo("doesnotexist"));
+ Assert.Null(ctx.CurrentTopic);
+ }
+
+ [Fact]
+ public void NavigateUp_FromTopic_ReturnsToRoot()
+ {
+ var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ ctx.NavigateUp();
+ Assert.Null(ctx.CurrentTopic);
+ Assert.Equal("~", ctx.CurrentPath);
+ }
+
+ [Fact]
+ public void NavigateUp_FromSubfolder_ReturnsToTopic()
+ {
+ var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ ctx.NavigateTo("sub");
+ ctx.NavigateUp();
+
+ Assert.Equal("psychologie", ctx.CurrentTopic);
+ Assert.Null(ctx.CurrentSubPath);
+ }
+
+ // ── SlugRelative ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SlugRelative_InTopic_StripsTopicPrefix()
+ {
+ var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ Assert.Equal("flow", ctx.SlugRelative("psychologie/flow"));
+ }
+
+ [Fact]
+ public void SlugRelative_AtRoot_ReturnsFullSlug()
+ {
+ var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
+ Assert.Equal("psychologie/flow", ctx.SlugRelative("psychologie/flow"));
+ }
+
+ // ── NotesInView ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NotesInView_InTopic_FiltersCorrectly()
+ {
+ var ctx = BuildCtx(
+ Note("psychologie/a", "psychologie"),
+ Note("biosysteme/b", "biosysteme"));
+
+ ctx.NavigateTo("psychologie");
+ var notes = ctx.NotesInView().ToList();
+
+ Assert.Single(notes);
+ Assert.Equal("psychologie/a", notes[0].Slug);
+ }
+
+ // ── ResolveSlug ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ResolveSlug_ShortName_InTopic_ResolvesToFullSlug()
+ {
+ var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
+ ctx.NavigateTo("psychologie");
+ Assert.Equal("psychologie/flow", ctx.ResolveSlug("flow"));
+ }
+
+ [Fact]
+ public void ResolveSlug_AlreadyFullSlug_ReturnsUnchanged()
+ {
+ var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
+ Assert.Equal("psychologie/flow", ctx.ResolveSlug("psychologie/flow"));
+ }
+}
diff --git a/NozCli.Tests/SlugGuardTests.cs b/NozCli.Tests/SlugGuardTests.cs
new file mode 100644
index 0000000..e8d7f69
--- /dev/null
+++ b/NozCli.Tests/SlugGuardTests.cs
@@ -0,0 +1,68 @@
+using Xunit;
+using NozCli.Core.Security;
+
+namespace NozCli.Tests;
+
+public class SlugGuardTests
+{
+ // ── IsValid ──────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("simple-note", true)]
+ [InlineData("psychologie/test", true)]
+ [InlineData("biosysteme/sub/deep", true)]
+ [InlineData("note123", true)]
+ [InlineData("a", true)]
+ public void IsValid_AcceptsValidSlugs(string slug, bool expected)
+ => Assert.Equal(expected, SlugGuard.IsValid(slug));
+
+ [Theory]
+ [InlineData("../../etc/passwd", false)] // path traversal
+ [InlineData("../escape", false)] // path traversal
+ [InlineData("note/../other", false)] // embedded traversal
+ [InlineData("UPPERCASE", false)] // uppercase not allowed
+ [InlineData("has space", false)] // spaces not allowed
+ [InlineData("", false)] // empty
+ [InlineData(" ", false)] // whitespace
+ [InlineData("note//double", false)] // double slash
+ [InlineData("/leading-slash", false)] // leading slash
+ [InlineData("trailing/", false)] // trailing slash
+ [InlineData("Sonderzeichen!", false)] // special chars
+ public void IsValid_RejectsInvalidSlugs(string slug, bool expected)
+ => Assert.Equal(expected, SlugGuard.IsValid(slug));
+
+ // ── ToSafePath ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ToSafePath_ValidSlug_ReturnsPathInsideBase()
+ {
+ var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
+ var result = SlugGuard.ToSafePath(baseDir, "psychologie/test");
+
+ Assert.NotNull(result);
+ Assert.StartsWith(baseDir, result);
+ Assert.EndsWith(".md", result);
+ Assert.Contains("psychologie", result);
+ }
+
+ [Theory]
+ [InlineData("../../etc/passwd")]
+ [InlineData("../escape")]
+ [InlineData("sub/../../outside")]
+ public void ToSafePath_TraversalSlug_ReturnsNull(string slug)
+ {
+ var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
+ Assert.Null(SlugGuard.ToSafePath(baseDir, slug));
+ }
+
+ [Fact]
+ public void ToSafePath_NestedSlug_BuildsCorrectPath()
+ {
+ var baseDir = "/tmp/vault";
+ var result = SlugGuard.ToSafePath(baseDir, "a/b/c");
+
+ Assert.NotNull(result);
+ var expected = Path.Combine("/tmp/vault", "a", "b", "c") + ".md";
+ Assert.Equal(expected, result);
+ }
+}
diff --git a/NozCli/Commands/PushCommand.cs b/NozCli/Commands/PushCommand.cs
index 27fb9e6..7d9deac 100644
--- a/NozCli/Commands/PushCommand.cs
+++ b/NozCli/Commands/PushCommand.cs
@@ -1,5 +1,5 @@
using NozCli.Repl;
-using NozCli.Security;
+using NozCli.Core.Security;
using Spectre.Console;
namespace NozCli.Commands;
diff --git a/NozCli/Commands/SyncCommand.cs b/NozCli/Commands/SyncCommand.cs
index c9155be..584fa78 100644
--- a/NozCli/Commands/SyncCommand.cs
+++ b/NozCli/Commands/SyncCommand.cs
@@ -1,7 +1,7 @@
using NozCli.Core.Client;
using NozCli.Core.Models;
using NozCli.Repl;
-using NozCli.Security;
+using NozCli.Core.Security;
using Spectre.Console;
namespace NozCli.Commands;
diff --git a/NozCli/Repl/ReplContext.cs b/NozCli/Repl/ReplContext.cs
index 2b3089b..408460a 100644
--- a/NozCli/Repl/ReplContext.cs
+++ b/NozCli/Repl/ReplContext.cs
@@ -70,6 +70,7 @@ public class ReplContext
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
return NotesInView()
+ .Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
.Where(s => s is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)!;
diff --git a/noz-cli.slnx b/noz-cli.slnx
index 118ed83..662cec8 100644
--- a/noz-cli.slnx
+++ b/noz-cli.slnx
@@ -2,4 +2,5 @@
+