← All source files

V16 / 6df58cd3ed86

WorkspaceFileSystemExtensions.cs

Lilith/Core/Workspace/Extensions/WorkspaceFileSystemExtensions.cs

Unchanged from the preceding version
CS
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;
        }
    }
}