diff --git a/src/SharpFM/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs index 6014e07..daa35bf 100644 --- a/src/SharpFM/Models/ClipRepository.cs +++ b/src/SharpFM/Models/ClipRepository.cs @@ -234,19 +234,41 @@ private static IReadOnlyList GetRelativeFolderSegments(string root, stri return decoded; } - // Reject traversal segments (security) and percent-encode any character - // the filesystem rejects so FileMaker names containing '/' or ':' survive - // the round-trip instead of being silently dropped. - private static IReadOnlyList SanitizeFolderPath(IReadOnlyList segments) + /// + /// Raised when drops one or more segments + /// (empty, whitespace, or traversal). Hosts can subscribe to surface a + /// non-blocking notification so plugin-driven placements that landed + /// somewhere other than requested are visible to the user. + /// + public event EventHandler? FolderPathSanitized; + + // Empty / "." / ".." segments are dropped (not encoded) — the call is + // best-effort, not a hard error, but the dropped segments are reported via + // Log.Warn and FolderPathSanitized so the caller can surface them. + private IReadOnlyList SanitizeFolderPath(IReadOnlyList segments) { if (segments is null || segments.Count == 0) return []; var safe = new List(segments.Count); + var dropped = new List(); foreach (var raw in segments) { - if (string.IsNullOrWhiteSpace(raw)) continue; - if (raw == "." || raw == "..") continue; + if (string.IsNullOrWhiteSpace(raw) || raw == "." || raw == "..") + { + dropped.Add(raw ?? ""); + continue; + } safe.Add(EncodeName(raw)); } + + if (dropped.Count > 0) + { + Log.Warn( + "Sanitized folder path: dropped [{Dropped}]; kept [{Kept}].", + string.Join(", ", dropped), + string.Join("/", safe)); + FolderPathSanitized?.Invoke(this, new FolderPathSanitizedEventArgs(segments, safe, dropped)); + } + return safe; } diff --git a/src/SharpFM/Models/FolderPathSanitizedEventArgs.cs b/src/SharpFM/Models/FolderPathSanitizedEventArgs.cs new file mode 100644 index 0000000..1d8740b --- /dev/null +++ b/src/SharpFM/Models/FolderPathSanitizedEventArgs.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace SharpFM.Models; + +/// +/// Payload for . Reports the +/// original folder path, the path that was actually used, and the segments +/// that were dropped during sanitization. +/// +public sealed class FolderPathSanitizedEventArgs : EventArgs +{ + public IReadOnlyList Original { get; } + public IReadOnlyList Sanitized { get; } + public IReadOnlyList DroppedSegments { get; } + + public FolderPathSanitizedEventArgs( + IReadOnlyList original, + IReadOnlyList sanitized, + IReadOnlyList droppedSegments) + { + Original = original; + Sanitized = sanitized; + DroppedSegments = droppedSegments; + } +} diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 58cd892..8026dbc 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -230,14 +230,32 @@ public async Task SaveClipsStorageAsync() }) .ToList(); - // Folders persist first so the orphan sweep in SaveClipsAsync - // sees up-to-date marker files when reclaiming empty directories. - await _repository.SaveFoldersAsync(Folders.ToList()); - await _repository.SaveClipsAsync(clipData); + var sanitized = 0; + void OnSanitized(object? _, FolderPathSanitizedEventArgs __) => sanitized++; + var fileRepo = _repository as ClipRepository; + if (fileRepo is not null) fileRepo.FolderPathSanitized += OnSanitized; + + try + { + // Folders persist first so the orphan sweep in SaveClipsAsync + // sees up-to-date marker files when reclaiming empty directories. + await _repository.SaveFoldersAsync(Folders.ToList()); + await _repository.SaveClipsAsync(clipData); + } + finally + { + if (fileRepo is not null) fileRepo.FolderPathSanitized -= OnSanitized; + } foreach (var c in FileMakerClips) c.MarkSaved(); - ShowStatus($"Saved {clipData.Count} clip(s) to {_repository.CurrentLocation}"); + var hadSanitization = sanitized > 0; + var suffix = hadSanitization + ? $"; sanitized {sanitized} folder path(s) — see log for dropped segments" + : ""; + ShowStatus( + $"Saved {clipData.Count} clip(s) to {_repository.CurrentLocation}{suffix}", + isError: hadSanitization); } catch (Exception e) { diff --git a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs index 6a1c709..35c317a 100644 --- a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs +++ b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs @@ -502,4 +502,99 @@ await repo.SaveClipsAsync([new("Evil", "Mac-XMSS", "") if (Directory.Exists(sibling)) Directory.Delete(sibling, true); } } + + [Fact] + public async Task SaveClipsAsync_TraversalSegment_RaisesFolderPathSanitizedEvent() + { + var dir = CreateTempDir(); + try + { + var repo = new ClipRepository(dir); + FolderPathSanitizedEventArgs? captured = null; + repo.FolderPathSanitized += (_, e) => captured = e; + + await repo.SaveClipsAsync([new("X", "Mac-XMSS", "") + { FolderPath = new[] { "..", "Real" } }]); + + Assert.NotNull(captured); + Assert.Equal(new[] { "..", "Real" }, captured!.Original); + Assert.Equal(new[] { "Real" }, captured.Sanitized); + Assert.Equal(new[] { ".." }, captured.DroppedSegments); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public async Task SaveClipsAsync_EmptySegment_RaisesEventNamingDropped() + { + var dir = CreateTempDir(); + try + { + var repo = new ClipRepository(dir); + FolderPathSanitizedEventArgs? captured = null; + repo.FolderPathSanitized += (_, e) => captured = e; + + await repo.SaveClipsAsync([new("X", "Mac-XMSS", "") + { FolderPath = new[] { "Real", " " } }]); + + Assert.NotNull(captured); + Assert.Equal(new[] { "Real" }, captured!.Sanitized); + Assert.Single(captured.DroppedSegments); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public async Task SaveClipsAsync_AllValidSegments_DoesNotRaiseEvent() + { + var dir = CreateTempDir(); + try + { + var repo = new ClipRepository(dir); + var fired = 0; + repo.FolderPathSanitized += (_, _) => fired++; + + await repo.SaveClipsAsync([new("X", "Mac-XMSS", "") + { FolderPath = new[] { "Group", "Sub" } }]); + + Assert.Equal(0, fired); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public async Task SaveClipsAsync_PercentEncodedOnly_DoesNotRaiseEvent() + { + var dir = CreateTempDir(); + try + { + var repo = new ClipRepository(dir); + var fired = 0; + repo.FolderPathSanitized += (_, _) => fired++; + + await repo.SaveClipsAsync([new("X", "Mac-XMSS", "") + { FolderPath = new[] { "Date/Time" } }]); + + Assert.Equal(0, fired); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public async Task SaveFoldersAsync_TraversalSegment_RaisesFolderPathSanitizedEvent() + { + var dir = CreateTempDir(); + try + { + var repo = new ClipRepository(dir); + FolderPathSanitizedEventArgs? captured = null; + repo.FolderPathSanitized += (_, e) => captured = e; + + await repo.SaveFoldersAsync([new(new[] { "..", "Real" })]); + + Assert.NotNull(captured); + Assert.Equal(new[] { ".." }, captured!.DroppedSegments); + } + finally { Directory.Delete(dir, true); } + } } diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs index 7da5e72..08a491f 100644 --- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -421,6 +421,34 @@ public async Task CopySelectedToClip_NoSelection_ShowsStatus() Assert.Equal("No clip selected", vm.StatusMessage); } + [Fact] + public async Task SaveClips_SanitizationDropsSegment_StatusIncludesSanitizedSuffix() + { + var vm = CreateVm(); + ResetRepoState(vm); + var clip = Clip.FromXml("Test", "Mac-XMSC", ""); + vm.FileMakerClips.Add(new ClipViewModel(clip) { FolderPath = new[] { "..", "Real" } }); + + await vm.SaveClipsStorageAsync(); + + Assert.Contains("Saved", vm.StatusMessage); + Assert.Contains("sanitized 1 folder path", vm.StatusMessage); + } + + [Fact] + public async Task SaveClips_NoSanitization_StatusOmitsSuffix() + { + var vm = CreateVm(); + ResetRepoState(vm); + var clip = Clip.FromXml("Test", "Mac-XMSC", ""); + vm.FileMakerClips.Add(new ClipViewModel(clip)); + + await vm.SaveClipsStorageAsync(); + + Assert.Contains("Saved", vm.StatusMessage); + Assert.DoesNotContain("sanitized", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task PasteFileMakerClipData_NoFormats_ShowsStatus() {