V17 / d6d373561692
WorkspaceFileSystemExtensions.cs
Lilith/Core/Workspace/Extensions/WorkspaceFileSystemExtensions.cs
Unchanged from the preceding versionCS
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Lilith.Core.Workspace.Extensions;
internal static class WorkspaceFileSystemExtensions
{
internal static string GetMetadataDirectory(this WorkspaceOptions options) => Path.Combine(options.RootDirectory, ".lilith");
internal static bool IsTextFile(this string path) => new[] { ".md", ".txt", ".json", ".csv", ".xml", ".html" }.Contains(Path.GetExtension(path).ToLowerInvariant());
internal static void EnsureNoLinks(this string path)
{
for (string? current = path; current is not null; current = Path.GetDirectoryName(current))
if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
throw new IOException("Workspace links/junctions are not supported.");
}
internal static string ResolvePath(this WorkspaceOptions options, string relative)
{
if (string.IsNullOrWhiteSpace(relative) || relative.Length > 240 || Path.IsPathRooted(relative)) throw new ArgumentException("Use a nonempty workspace-relative path.");
var parts = relative.Replace('\\', '/').Split('/');
foreach (string part in parts)
if (string.IsNullOrWhiteSpace(part) || part is "." or ".." || part.StartsWith('.') || part.EndsWith(' ') || part.EndsWith('.') ||
part.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || part.Contains(':') ||
System.Text.RegularExpressions.Regex.IsMatch(part, @"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
throw new ArgumentException("Invalid or reserved workspace path component.");
string path = Path.GetFullPath(Path.Combine(options.RootDirectory, Path.Combine(parts)));
if (!path.StartsWith(options.RootDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Path leaves the workspace.");
EnsureNoLinks(path);
return path;
}
internal static string GetMetadataPath(this WorkspaceOptions options, string category, string id)
{
if (!Guid.TryParseExact(id, "N", out _)) throw new ArgumentException("Invalid operation/bin ID.");
string path = Path.Combine(options.GetMetadataDirectory(), category, id);
path.EnsureNoLinks();
return path;
}
internal static string ComputeSignature(this string path)
{
EnsureNoLinks(path);
if (!File.Exists(path) && !Directory.Exists(path)) return "missing";
if (File.Exists(path))
{
using var file = File.OpenRead(path);
if (file.Length > 64000000) throw new IOException("Mutation verification supports files up to 64 MB.");
return "file:" + Convert.ToHexString(SHA256.HashData(file));
}
var entries = EnumerateEntries(path, 10000, true).OrderBy(p => p, StringComparer.Ordinal).ToArray();
string manifest = string.Join('\n', entries.Select(p => Path.GetRelativePath(path, p) + ":" +
(Directory.Exists(p) ? "directory" : ComputeSignature(p))));
return "directory:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest)));
}
internal static IEnumerable<string> EnumerateEntries(this string root, int limit, bool includeHidden = false)
{
var stack = new Stack<string>();
stack.Push(root);
int count = 0;
while (stack.Count > 0)
foreach (string item in Directory.EnumerateFileSystemEntries(stack.Pop()))
{
if (!includeHidden && Path.GetFileName(item).StartsWith('.')) continue;
EnsureNoLinks(item);
if (++count > limit) throw new IOException("Workspace scan limit reached; narrow the folder.");
yield return item;
if (Directory.Exists(item)) stack.Push(item);
}
}
internal static async Task WriteAtomicallyAsync(this string path, string content, CancellationToken ct)
{
path.EnsureNoLinks();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temporary = path + ".pending-" + Guid.NewGuid().ToString("N");
try
{
await File.WriteAllTextAsync(temporary, content, new UTF8Encoding(false), ct);
File.Move(temporary, path, true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
internal static string GetOriginalBinPath(this WorkspaceOptions options, string id) => JsonSerializer.Deserialize<WorkspaceRequest>(File.ReadAllText(Path.Combine(options.GetMetadataPath("bin", id), "original.json")))!.Path;
internal static void MoveTo(this string from, string to)
{
switch (Directory.Exists(from))
{
case true:
Directory.Move(from, to);
break;
case false:
File.Move(from, to);
break;
}
}
}