diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index 94c3505bd..9eeb4a83e 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -375,8 +375,15 @@ internal sealed class MossTankRouteProfileStore // File naming, storage plumbing. // ------------------------------------------------------------------ + // Round 3 item 3: the hidden "--" prefix MUST come before the "nav_" + // kind marker (--nav_Name_Server.af) — putting the marker first + // (nav_--Name_Server.af, the pre-fix shape) means the filename does not + // start with "--" at all, defeating the StartsWith("--") hidden-file + // check both ListNavigationProfiles and ListMetaProfiles rely on and + // leaking this character's private per-character route to every other + // character's picker. private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) - ? NavPrefix + VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af") + ? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix) : _selected; private static string ToFileName(string bareName) => diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs index 53d80ff86..b093482da 100644 --- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -35,6 +35,22 @@ internal static class VtankProfileDirectory internal const string DefaultLabel = "[Default]"; internal const string NoneLabel = "[None]"; + /// + /// The nav_ marker (round 3 items 3/4) that distinguishes a + /// stand-alone route .af from a Meta profile sharing the same + /// flat directory and extension + /// — metaf's own observed convention for a nav-only .af (see the + /// committed nav_*.af fixtures). MUST be checked AFTER stripping + /// when the marker is combined with it (see + /// 's + /// marker parameter): a hidden per-character route file is named + /// --nav_Name_Server.af — hidden prefix FIRST, marker SECOND — + /// never nav_--Name_Server.af, which does not start with + /// at all and so defeats every + /// StartsWith("--") hidden-file check in this class. + /// + internal const string NavMarker = "nav_"; + /// /// VTank's single per-character default filename /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. @@ -45,6 +61,21 @@ internal static class VtankProfileDirectory string extension) => $"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}"; + /// + /// Overload for a per-character auto file that ALSO carries a kind + /// marker (currently only , for + /// 's route .af): the + /// hidden prefix always comes first so the file stays hidden from every + /// other character's picker exactly like the unmarked overload above — + /// --{marker}Name_Server.ext, e.g. --nav_Name_Server.af. + /// + public static string AutoCharacterFileName( + string characterName, + string server, + string extension, + string marker) => + $"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}"; + /// /// The longer, trailing-underscore prefix /// (uTank2/PluginCore.cs:933,3866, field dw) that marks a @@ -155,7 +186,10 @@ internal static class VtankProfileDirectory /// VTank's navigation-profile list (l(), /// uTank2/PluginCore.cs:7156-7185): seeds /// /, then every - /// .af file that starts with neither -- nor ~~. + /// -marked .af file that starts with + /// neither -- nor ~~. Round 3 item 4: a Meta profile and a + /// route share this same flat, single-extension directory — without + /// the marker check this returned every Meta .af too. /// public static IReadOnlyList ListNavigationProfiles(IPluginStorage storage) { @@ -171,6 +205,8 @@ internal static class VtankProfileDirectory { continue; } + if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal)) + continue; entries.Add(new ProfileEntry(fileName, fileName)); } return entries; @@ -180,11 +216,12 @@ internal static class VtankProfileDirectory /// VTank's meta-profile list (ac(), /// uTank2/PluginCore.cs:7187+): seeds /// /, then every - /// non--- file (a meta and a nav profile share the same - /// directory and extension here — .af — so callers pass a - /// distinguishing sub-extension convention if they need one; VTank - /// itself distinguished by the separate .met/.nav - /// extensions). + /// non---, non--marked file (a meta and a + /// nav profile share the same directory and extension here — + /// .af — so the marker is what VTank's own separate + /// .met/.nav extensions used to provide; round 3 item 4: + /// without excluding files this returned every + /// route .af too). /// public static IReadOnlyList ListMetaProfiles(IPluginStorage storage) { @@ -197,6 +234,8 @@ internal static class VtankProfileDirectory { if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)) continue; + if (fileName.StartsWith(NavMarker, StringComparison.Ordinal)) + continue; entries.Add(new ProfileEntry(fileName, fileName)); } return entries; diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index e4c71ff64..0aec9e989 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -514,8 +514,9 @@ public sealed class NavigationTests var storage = new MemoryStorage(); string legacyKey = LegacyRouteByCharacterKey("Barris"); storage.Text[legacyKey] = """{ "Mode": 1, "Waypoints": [] }"""; - string realKey = "nav_" + VtankProfileDirectory.AutoCharacterFileName( - "Barris", string.Empty, "af"); + // Round 3 item 3: hidden prefix first, nav_ marker second. + string realKey = VtankProfileDirectory.AutoCharacterFileName( + "Barris", string.Empty, "af", VtankProfileDirectory.NavMarker); var real = new NavigationSettings { Mode = RouteMode.Circular }; real.Waypoints.Add(new RouteWaypoint { diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs index f5907cf38..76862fabe 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs @@ -74,8 +74,10 @@ public sealed class VtankProfileDirectoryTests public void ListNavigationProfilesFiltersBothReservedPrefixes() { var storage = new MemoryStorage(); - storage.WriteText("Hunt.af", "1\r\n"); - storage.WriteText("--Barris_Coldeve.af", "1\r\n"); + // Round 3 item 4: a route .af must carry the nav_ marker to be + // listed at all — an un-marked file is a Meta profile, not a route. + storage.WriteText("nav_Hunt.af", "1\r\n"); + storage.WriteText("--nav_Barris_Coldeve.af", "1\r\n"); storage.WriteText("~~backup.af", "1\r\n"); IReadOnlyList entries = @@ -83,11 +85,74 @@ public sealed class VtankProfileDirectoryTests Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName); Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName); - Assert.Contains(entries, static e => e.DisplayName == "Hunt.af"); + Assert.Contains(entries, static e => e.DisplayName == "nav_Hunt.af"); Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal)); Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal)); } + /// + /// Round 3 items 3/4: a Meta profile and a route share the exact same + /// flat directory and .af extension, so the nav_ marker (and the + /// hidden "--" prefix coming BEFORE it on a per-character auto file) is + /// the only thing that keeps the two pickers from showing each other's + /// files. This is the disambiguation test both pickers must pass + /// together against one mixed directory. + /// + [Fact] + public void NavigationAndMetaPickersPartitionTheSameMixedDirectory() + { + var storage = new MemoryStorage(); + storage.WriteText("nav_Hunt.af", "1\r\n"); + storage.WriteText("MyMeta.af", "1\r\n"); + // Barris's own hidden per-character route: hidden prefix FIRST, + // marker SECOND (item 3's fix) — must be invisible to BOTH pickers, + // not just the nav one. + storage.WriteText("--nav_Barris_Coldeve.af", "1\r\n"); + // Barris's own hidden per-character Meta file (unmarked, as + // MossTankMetaProfileStore has always named it) — must likewise be + // invisible to both. + storage.WriteText("--Barris_Coldeve.af", "1\r\n"); + + IReadOnlyList navEntries = + VtankProfileDirectory.ListNavigationProfiles(storage); + IReadOnlyList metaEntries = + VtankProfileDirectory.ListMetaProfiles(storage); + + Assert.Contains(navEntries, static e => e.FileName == "nav_Hunt.af"); + Assert.DoesNotContain(navEntries, static e => e.FileName == "MyMeta.af"); + Assert.DoesNotContain(navEntries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal)); + + Assert.Contains(metaEntries, static e => e.FileName == "MyMeta.af"); + Assert.DoesNotContain(metaEntries, static e => e.FileName == "nav_Hunt.af"); + Assert.DoesNotContain(metaEntries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal)); + } + + /// + /// Round 3 item 3 (BLOCKER-adjacent leak): before the fix, a hidden + /// per-character route file was named nav_--Name_Server.af + /// (marker first) — which does NOT start with "--" and so defeated the + /// hidden-file check in BOTH pickers, leaking another character's + /// private route/Meta binding. + /// + [Fact] + public void AnotherCharactersAutoRouteIsHiddenFromBothPickers() + { + var storage = new MemoryStorage(); + // The CORRECT (post-fix) shape: hidden prefix first, marker second. + storage.WriteText( + VtankProfileDirectory.AutoCharacterFileName( + "Someone", "Coldeve", "af", VtankProfileDirectory.NavMarker), + "1\r\n"); + + IReadOnlyList navEntries = + VtankProfileDirectory.ListNavigationProfiles(storage); + IReadOnlyList metaEntries = + VtankProfileDirectory.ListMetaProfiles(storage); + + Assert.DoesNotContain(navEntries, static e => e.FileName.Contains("Someone", StringComparison.Ordinal)); + Assert.DoesNotContain(metaEntries, static e => e.FileName.Contains("Someone", StringComparison.Ordinal)); + } + [Fact] public void ListingsOnUnavailableStorageOnlySeedTheBuiltInEntries() {