child/LinuxToolRuntime.cs

#nullable enable
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using Microsoft.Win32.SafeHandles;

namespace DeskPilot.Child
{
    /// <summary>Protected PID-1 lease and filesystem initialization.</summary>
    public static class ToolSupervisor
    {
        /// <summary>Runs independently of all unprivileged Tool processes.</summary>
        public static void Run(int leaseSeconds, string access)
        {
            if (!OperatingSystem.IsLinux() || getuid() != 0 || getpid() != 1 ||
                leaseSeconds < 1 || leaseSeconds > 30 ||
                (access != "read-only" && access != "read-write"))
            {
                throw new InvalidOperationException("The protected Tool supervisor profile is invalid.");
            }
            long renewed = Stopwatch.GetTimestamp();
            using var timer = new Timer(_ =>
            {
                if (Stopwatch.GetElapsedTime(Interlocked.Read(ref renewed)).TotalSeconds >= leaseSeconds)
                {
                    Environment.Exit(124);
                }
            }, null, 100, 100);
            foreach (string directory in new[] { "/work/project", "/work/control", "/work/tmp", "/work/home" })
            {
                Directory.CreateDirectory(directory);
                File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
            }
            foreach (string directory in new[] { "/work/tmp", "/work/home" })
            {
                if (chown(directory, 10001, 10001) != 0) { throw new IOException("Tool storage ownership failed."); }
            }
            Stream input = Console.OpenStandardInput();
            var key = new byte[32];
            input.ReadExactly(key);
            using var channel = new AuthenticatedChannel(input, Console.OpenStandardOutput(), key, false, 16384);
            System.Security.Cryptography.CryptographicOperations.ZeroMemory(key);
            channel.Send("{\"type\":\"ready\"}");
            bool sealedInput = false;
            while (true)
            {
                string message;
                try { message = channel.Receive(); }
                catch (EndOfStreamException) { Environment.Exit(125); return; }
                using JsonDocument request = JsonDocument.Parse(message);
                string type = request.RootElement.GetProperty("type").GetString()!;
                if (type == "renew") { Interlocked.Exchange(ref renewed, Stopwatch.GetTimestamp()); }
                else if (type == "stop") { Environment.Exit(125); }
                else if (type == "freeze" && sealedInput)
                {
                    LinuxToolFile.StopToolProcesses();
                    channel.Send("{\"type\":\"frozen\"}");
                }
                else if (type == "seal" && !sealedInput)
                {
                    foreach (string entry in Directory.EnumerateFileSystemEntries("/work/project", "*", SearchOption.AllDirectories))
                    {
                        bool directory = Directory.Exists(entry);
                        File.SetUnixFileMode(entry, access == "read-write"
                            ? (directory ? (UnixFileMode)448 : (UnixFileMode)384)
                            : (directory ? (UnixFileMode)365 : (UnixFileMode)292));
                        if (access == "read-write" && chown(entry, 10001, 10001) != 0)
                        {
                            throw new IOException("Tool Project ownership failed.");
                        }
                    }
                    File.SetUnixFileMode("/work/project", access == "read-write" ? (UnixFileMode)448 : (UnixFileMode)365);
                    if (access == "read-write" && chown("/work/project", 10001, 10001) != 0)
                    {
                        throw new IOException("Tool Project ownership failed.");
                    }
                    sealedInput = true;
                    channel.Send("{\"type\":\"sealed\"}");
                }
                else { throw new InvalidDataException("An unexpected Tool control record was refused."); }
            }
        }

        [DllImport("libc", SetLastError = true)]
        private static extern int chown(string path, uint owner, uint group);
        [DllImport("libc")]
        private static extern uint getuid();
        [DllImport("libc")]
        private static extern int getpid();
    }

    /// <summary>Kernel-resolved File access inside the private Tool filesystem.</summary>
    public static class LinuxToolFile
    {
        /// <summary>Performs one bounded File operation from a fixed entry point.</summary>
        public static string Invoke(string operation, string path, int offset, int count, long inputLength)
        {
            if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
            {
                throw new PlatformNotSupportedException("The Tool File boundary requires Linux x64 openat2.");
            }
            string relative = ProjectBaseline.ValidateRelativePath(path);
            if (offset < 0 || count < 1 || count > 8192 || inputLength < 0 || inputLength > 67108864)
            {
                throw new InvalidDataException("The File request exceeds its limit.");
            }
            if (operation == "seed")
            {
                if (getuid() != 0) { throw new UnauthorizedAccessException("Seed is a Host Server operation."); }
                Directory.CreateDirectory(Path.GetDirectoryName("/work/project/" + relative)!);
            }
            int root = open("/work/project", 0x90000, 0);
            if (root < 0) { throw new IOException("The Tool Project is not available."); }
            using var rootHandle = new SafeFileHandle(new IntPtr(root), true);
            bool write = operation == "write" || operation == "seed";
            if (!write && operation != "read") { throw new InvalidDataException("Unknown File operation."); }
            var how = new OpenHow { Flags = (ulong)(0x80000 | (write ? 0x41 : 0)), Mode = write ? 384UL : 0UL, Resolve = 15 };
            int descriptor = (int)syscall(437, root, relative, ref how, (ulong)Marshal.SizeOf<OpenHow>());
            if (descriptor < 0) { return Failure(Marshal.GetLastWin32Error()); }
            using var handle = new SafeFileHandle(new IntPtr(descriptor), true);
            if (fstat(descriptor, out FileStat metadata) != 0 || (metadata.Mode & 0xF000) != 0x8000 || metadata.Links != 1)
            {
                return "{\"ok\":false,\"code\":\"unsafe_file\"}";
            }
            try
            {
                using var file = new FileStream(handle, write ? FileAccess.Write : FileAccess.Read, 4096, false);
                if (write)
                {
                    file.SetLength(0);
                    Stream input = Console.OpenStandardInput();
                    var buffer = new byte[16384];
                    long remaining = inputLength;
                    while (remaining > 0)
                    {
                        int received = input.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining));
                        if (received == 0) { throw new EndOfStreamException("Incomplete File input."); }
                        file.Write(buffer, 0, received);
                        remaining -= received;
                    }
                    file.Flush(true);
                    return JsonSerializer.Serialize(new { ok = true, bytes = inputLength });
                }
                file.Position = Math.Min(offset, file.Length);
                var bytes = new byte[(int)Math.Min(count, file.Length - file.Position)];
                file.ReadExactly(bytes);
                return JsonSerializer.Serialize(new
                {
                    ok = true, path = relative, offset, bytes = bytes.Length, totalBytes = file.Length,
                    hasMore = file.Position < file.Length, text = Encoding.UTF8.GetString(bytes),
                    base64 = Convert.ToBase64String(bytes)
                });
            }
            catch (IOException error)
            {
                return Failure(error.HResult & 0xFFFF);
            }
        }

        /// <summary>Reads kernel filesystem quota counters.</summary>
        public static string Inspect()
        {
            if (statvfs("/work", out FileSystemStat state) != 0) { throw new IOException("Tool quota inspection failed."); }
            return JsonSerializer.Serialize(new
            {
                bytes = state.Blocks * state.FragmentSize,
                freeBytes = state.AvailableBlocks * state.FragmentSize,
                inodes = state.Files, freeInodes = state.AvailableFiles
            });
        }

        /// <summary>Terminates the Tool identity before export without stopping the lease.</summary>
        public static void StopToolProcesses()
        {
            var clock = Stopwatch.StartNew();
            while (clock.Elapsed.TotalSeconds < 5)
            {
                bool found = false;
                foreach (string directory in Directory.EnumerateDirectories("/proc"))
                {
                    if (!int.TryParse(Path.GetFileName(directory), out int processId)) { continue; }
                    int descriptor = open(directory, 0x90000, 0);
                    if (descriptor < 0) { continue; }
                    using var handle = new SafeFileHandle(new IntPtr(descriptor), true);
                    if (fstat(descriptor, out FileStat state) != 0 || state.User != 10001) { continue; }
                    found = true;
                    if (kill(processId, 9) != 0 && Marshal.GetLastWin32Error() != 3)
                    {
                        throw new IOException("Tool descendants could not be stopped for export.");
                    }
                }
                while (waitpid(-1, out _, 1) > 0) { }
                if (!found) { return; }
                Thread.Yield();
            }
            throw new IOException("Tool descendants remain; export was refused.");
        }

        /// <summary>Checks actual file identities and types after Tool execution is frozen.</summary>
        public static string ValidateExport(int entryLimit)
        {
            var pending = new System.Collections.Generic.Queue<string>();
            pending.Enqueue(string.Empty);
            int visited = 0;
            int root = open("/work/project", 0x90000, 0);
            if (root < 0) { throw new IOException("The export Project is missing."); }
            using var rootHandle = new SafeFileHandle(new IntPtr(root), true);
            while (pending.Count > 0)
            {
                string parent = pending.Dequeue();
                foreach (string entry in Directory.EnumerateFileSystemEntries("/work/project/" + parent))
                {
                    if (++visited > entryLimit) { throw new InvalidDataException("The export entry limit was exceeded."); }
                    string relative = Path.GetRelativePath("/work/project", entry);
                    ProjectBaseline.ValidateRelativePath(relative);
                    var how = new OpenHow { Flags = 0x280000, Resolve = 15 };
                    int descriptor = (int)syscall(437, root, relative, ref how, (ulong)Marshal.SizeOf<OpenHow>());
                    if (descriptor < 0) { throw new InvalidDataException("Unsafe export link or mount was refused."); }
                    using var handle = new SafeFileHandle(new IntPtr(descriptor), true);
                    if (fstat(descriptor, out FileStat state) != 0) { throw new IOException("Export identity inspection failed."); }
                    if ((state.Mode & 0xF000) == 0x4000) { pending.Enqueue(relative); }
                    else if ((state.Mode & 0xF000) != 0x8000 || state.Links != 1)
                    {
                        throw new InvalidDataException("Unsafe export file type or hard link was refused.");
                    }
                }
            }
            return "{\"ok\":true}";
        }

        private static string Failure(int code) => JsonSerializer.Serialize(new
        {
            ok = false,
            code = code == 28 || code == 122 ? "quota_exceeded" : code == 13 || code == 30 ? "access_denied" : "file_refused"
        });

        [StructLayout(LayoutKind.Sequential)]
        private struct OpenHow { public ulong Flags; public ulong Mode; public ulong Resolve; }
        [StructLayout(LayoutKind.Sequential)]
        private struct FileStat
        {
            public ulong Device; public ulong Inode; public ulong Links;
            public uint Mode; public uint User; public uint Group; public uint Padding;
            public ulong SpecialDevice; public long Size; public long BlockSize; public long Blocks;
            public long AccessSeconds; public long AccessNanos; public long WriteSeconds; public long WriteNanos;
            public long ChangeSeconds; public long ChangeNanos; public long Unused1; public long Unused2; public long Unused3;
        }
        [StructLayout(LayoutKind.Sequential)]
        private struct FileSystemStat
        {
            public ulong BlockSize; public ulong FragmentSize; public ulong Blocks; public ulong FreeBlocks;
            public ulong AvailableBlocks; public ulong Files; public ulong FreeFiles; public ulong AvailableFiles;
            public ulong Id; public ulong Flags; public ulong NameMaximum;
            public int Spare1; public int Spare2; public int Spare3; public int Spare4; public int Spare5; public int Spare6;
        }
        [DllImport("libc", SetLastError = true)]
        private static extern int open(string path, int flags, int mode);
        [DllImport("libc", SetLastError = true)]
        private static extern long syscall(long number, int root, string path, ref OpenHow how, ulong size);
        [DllImport("libc", SetLastError = true)]
        private static extern int fstat(int descriptor, out FileStat state);
        [DllImport("libc", SetLastError = true)]
        private static extern int statvfs(string path, out FileSystemStat state);
        [DllImport("libc")]
        private static extern uint getuid();
        [DllImport("libc", SetLastError = true)]
        private static extern int kill(int processId, int signal);
        [DllImport("libc", SetLastError = true)]
        private static extern int waitpid(int processId, out int status, int options);
    }
}