Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the plugin a raw string path and told it to fall back to its own System.IO-based portable default when null — a plugin reading and resolving filesystem paths itself, which is exactly the seam the rest of IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost scopes/validates every key; the plugin never sees a path). - IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles (IPluginStorage, defaults to NoOpPluginStorage) added — a second, UNSCOPED storage instance (unlike Storage, which Core scopes per plugin manifest id) rooted at a host-composed VTank-compatible directory. - ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly (no scoping — it names one shared external location, not per-plugin data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped proves the forwarded instance is the exact same object (Assert.Same), not a wrapper. - AppPluginHost/Program.cs: new vtankProfiles constructor parameter, composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride ?? Path.Combine(applicationPaths.DataDirectory, "vtank")). - RuntimeOptions.VtankProfileDirectoryOverride: new init-only property parsed from ACDREAM_VTANK_PROFILE_DIR (row added to docs/launch-options.md, side-effects column states the redirect is the only effect and documents the NullIfEmpty whitespace-not-special-cased quirk it shares with every other path-override flag). New RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet. - FilePluginStorage.List(prefix): empty prefix now means "the storage root itself" instead of throwing (Resolve() rejects empty/whitespace keys, which is correct for every OTHER caller but wrong for "list everything" — VtankProfileDirectory needs exactly that). - Headless: HeadlessPluginHost gained the same VtankProfiles property/constructor param, threaded through HeadlessPluginSession.Create -> HeadlessSessionHost -> HeadlessProcessHost, composed from the new HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides are HeadlessPathOverrides/CLI flags, not env vars). A small AcDream.Headless.Plugins.FilePluginStorage duplicates the App implementation byte-for-byte (Headless does not reference AcDream.App and no shared "platform plugins" library exists yet to host one copy; documented as a reasonable future consolidation, not required here). - VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted outright (no more System.IO, no plugin-owned portable-default fallback); ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take IPluginStorage and enumerate through EnumerateFileNames, which calls storage.List(string.Empty) and skips any key containing '/' (VTank's profile directory is flat; a nested key from some other IPluginStorage implementation is not a profile file). VtankProfileDirectoryTests rewritten against an in-memory IPluginStorage fake instead of real temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins that skip. The prior Resolve/PortableDefault-specific tests (Linux-path guarantee, host-override-vs-portable-default) are superseded by RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus the RuntimeOptions.FromEnvironment Path.Combine-only composition in Program.cs. - docs/architecture/acdream-architecture.md: one sentence in the Storage/List(prefix) paragraph naming VtankProfiles as the second, unscoped storage. No production caller of VtankProfileDirectory's listing methods exists yet (A2's foundation is not wired into MossTankProfileStore/ MossTankMetaProfileStore/MossTankRouteProfileStore's own selection — per that slice's own ledger note), so this is a contract + plumbing change with no MossTank runtime behavior change. MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests (Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests: 173/174 (the one failure, HeadlessCredentialResolverTests. LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing Linux-only lane gate that throws PlatformNotSupportedException on this Windows host — unrelated to this change). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
91 lines
3 KiB
C#
91 lines
3 KiB
C#
using System.Text;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.App.Plugins;
|
|
|
|
/// <summary>Crash-safe filesystem implementation behind scoped plugin keys.</summary>
|
|
internal sealed class FilePluginStorage : IPluginStorage
|
|
{
|
|
private readonly string _root;
|
|
|
|
internal FilePluginStorage(string root)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
|
_root = Path.GetFullPath(root);
|
|
}
|
|
|
|
public bool IsAvailable => true;
|
|
|
|
public string? ReadText(string key)
|
|
{
|
|
string path = Resolve(key);
|
|
return File.Exists(path)
|
|
? File.ReadAllText(path, Encoding.UTF8)
|
|
: null;
|
|
}
|
|
|
|
public IReadOnlyList<string> List(string prefix)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(prefix);
|
|
// An empty prefix means "the storage root itself" — Resolve()
|
|
// rejects an empty/whitespace key (every other caller of it means
|
|
// one specific file or sub-directory), so this is handled directly
|
|
// rather than relaxing that guard for every other use.
|
|
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
|
|
if (!Directory.Exists(directory))
|
|
return Array.Empty<string>();
|
|
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
|
.Select(path => Path.GetRelativePath(_root, path)
|
|
.Replace(Path.DirectorySeparatorChar, '/'))
|
|
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
public void WriteText(string key, string content)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(content);
|
|
string path = Resolve(key);
|
|
string directory = Path.GetDirectoryName(path)!;
|
|
Directory.CreateDirectory(directory);
|
|
string temporary = Path.Combine(
|
|
directory,
|
|
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
|
try
|
|
{
|
|
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
|
File.Move(temporary, path, overwrite: true);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(temporary))
|
|
File.Delete(temporary);
|
|
}
|
|
}
|
|
|
|
public bool Delete(string key)
|
|
{
|
|
string path = Resolve(key);
|
|
if (!File.Exists(path))
|
|
return false;
|
|
File.Delete(path);
|
|
return true;
|
|
}
|
|
|
|
private string Resolve(string key)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
|
if (Path.IsPathRooted(key))
|
|
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
|
string path = Path.GetFullPath(Path.Combine(_root, key));
|
|
string relative = Path.GetRelativePath(_root, path);
|
|
if (Path.IsPathRooted(relative)
|
|
|| relative.Equals("..", StringComparison.Ordinal)
|
|
|| relative.StartsWith(
|
|
".." + Path.DirectorySeparatorChar,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
|
}
|
|
return path;
|
|
}
|
|
}
|