184 lines
8.2 KiB
C#
184 lines
8.2 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace ClawdDotNet.Core.Runtimes;
|
|
|
|
/// <summary>#712: One change a foreign run proposes.</summary>
|
|
public sealed record ProposedChange(
|
|
[property: JsonPropertyName("path")] string Path,
|
|
[property: JsonPropertyName("change")] string Change,
|
|
[property: JsonPropertyName("bytes")] long Bytes,
|
|
[property: JsonPropertyName("sha256")] string? Sha256)
|
|
{
|
|
public const string Added = "added";
|
|
public const string Modified = "modified";
|
|
public const string Deleted = "deleted";
|
|
}
|
|
|
|
/// <summary>#712: What a foreign run left behind — never applied, kept for review.</summary>
|
|
public sealed record Proposal
|
|
{
|
|
[JsonPropertyName("runId")] public string RunId { get; init; } = "";
|
|
[JsonPropertyName("agentId")] public string AgentId { get; init; } = "";
|
|
[JsonPropertyName("taskId")] public string? TaskId { get; init; }
|
|
[JsonPropertyName("runtime")] public string Runtime { get; init; } = "";
|
|
[JsonPropertyName("createdAtUtc")] public DateTime CreatedAtUtc { get; init; }
|
|
[JsonPropertyName("changes")] public IReadOnlyList<ProposedChange> Changes { get; init; } = [];
|
|
|
|
/// <summary>What the run left out: links, special files — named, never read.</summary>
|
|
[JsonPropertyName("ignored")] public IReadOnlyList<string> Ignored { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// #712: The foreign run's place to work — a copy of the agent's workspace, never the
|
|
/// workspace itself (rule 1 of the concept). What changed there becomes a proposal
|
|
/// (rule 3): the changed files and a list, next to the agent, for review. Whether any of it
|
|
/// is used is decided by the delivery chain (#695), not by the run.
|
|
///
|
|
/// <para><b>Reading what a sandbox left is reading hostile ground.</b> Only regular files
|
|
/// are read. A symbolic link could point to a key outside the sandbox — inside it the
|
|
/// target did not exist, outside it does; a named pipe would block the reader for good.
|
|
/// Both are listed as ignored and never opened. After the run every process of the sandbox
|
|
/// is gone, so nothing changes while it is read.</para>
|
|
/// </summary>
|
|
public static class ForeignWorkspace
|
|
{
|
|
public const long MaxBytes = 512L * 1024 * 1024;
|
|
public const int MaxFiles = 20_000;
|
|
|
|
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
|
|
|
|
/// <summary>Copies the workspace to <paramref name="target"/> — regular files and folders only.</summary>
|
|
public static void Copy(string workspace, string target)
|
|
{
|
|
Directory.CreateDirectory(target);
|
|
if (!Directory.Exists(workspace))
|
|
return;
|
|
|
|
long bytes = 0;
|
|
var files = 0;
|
|
foreach (var (relative, full) in Walk(workspace, _ => { }))
|
|
{
|
|
var info = new FileInfo(full);
|
|
bytes += info.Length;
|
|
if (++files > MaxFiles || bytes > MaxBytes)
|
|
throw new ForeignRunException(
|
|
$"The workspace is too large for a foreign run (more than {MaxFiles} files or {MaxBytes / (1024 * 1024)} MB).");
|
|
var destination = Path.Combine(target, relative);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
File.Copy(full, destination);
|
|
}
|
|
}
|
|
|
|
/// <summary>What differs between the workspace and the run's copy.</summary>
|
|
public static (IReadOnlyList<ProposedChange> Changes, IReadOnlyList<string> Ignored) Compare(string workspace, string copy)
|
|
{
|
|
var before = Directory.Exists(workspace)
|
|
? Walk(workspace, _ => { }).ToDictionary(e => e.Relative, e => e.Full, StringComparer.Ordinal)
|
|
: new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
var ignored = new List<string>();
|
|
var changes = new List<ProposedChange>();
|
|
long bytes = 0;
|
|
foreach (var (relative, full) in Walk(copy, ignored.Add))
|
|
{
|
|
var length = new FileInfo(full).Length;
|
|
if ((bytes += length) > MaxBytes || changes.Count >= MaxFiles)
|
|
throw new ForeignRunException("The run left more than a proposal can hold.");
|
|
|
|
var hash = Hash(full);
|
|
if (!before.Remove(relative, out var original))
|
|
changes.Add(new ProposedChange(relative, ProposedChange.Added, length, hash));
|
|
else if (new FileInfo(original).Length != length || Hash(original) != hash)
|
|
changes.Add(new ProposedChange(relative, ProposedChange.Modified, length, hash));
|
|
}
|
|
foreach (var relative in before.Keys)
|
|
changes.Add(new ProposedChange(relative, ProposedChange.Deleted, 0, null));
|
|
|
|
changes.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
|
|
return (changes, ignored);
|
|
}
|
|
|
|
/// <summary>Writes the proposal to <paramref name="directory"/>: <c>proposal.json</c> and the files under <c>files/</c>.</summary>
|
|
public static void Keep(Proposal proposal, string copy, string directory)
|
|
{
|
|
var files = Path.Combine(directory, "files");
|
|
Directory.CreateDirectory(files);
|
|
foreach (var change in proposal.Changes.Where(c => c.Change != ProposedChange.Deleted))
|
|
{
|
|
var destination = Path.Combine(files, change.Path);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
File.Copy(Path.Combine(copy, change.Path), destination, overwrite: true);
|
|
}
|
|
File.WriteAllText(Path.Combine(directory, "proposal.json"), JsonSerializer.Serialize(proposal, Json));
|
|
}
|
|
|
|
/// <summary>Regular files below <paramref name="root"/>, relative with '/'; everything else goes to <paramref name="skipped"/>.</summary>
|
|
private static IEnumerable<(string Relative, string Full)> Walk(string root, Action<string> skipped)
|
|
{
|
|
var pending = new Stack<string>([Path.GetFullPath(root)]);
|
|
var rootLength = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar).Length + 1;
|
|
while (pending.Count > 0)
|
|
{
|
|
var dir = pending.Pop();
|
|
foreach (var entry in new DirectoryInfo(dir).EnumerateFileSystemInfos())
|
|
{
|
|
var relative = entry.FullName[rootLength..].Replace('\\', '/');
|
|
switch (FileKind.Of(entry))
|
|
{
|
|
case FileKind.Kind.Directory: pending.Push(entry.FullName); break;
|
|
case FileKind.Kind.Regular: yield return (relative, entry.FullName); break;
|
|
default: skipped(relative); break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string Hash(string path)
|
|
{
|
|
using var stream = File.OpenRead(path);
|
|
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #712: What an entry really is, without following it. .NET tells links apart but not a
|
|
/// named pipe or a device from a file — under Linux that takes <c>statx</c>, whose layout,
|
|
/// unlike <c>stat</c>'s, is the same on every architecture.
|
|
/// </summary>
|
|
internal static class FileKind
|
|
{
|
|
public enum Kind { Regular, Directory, Other }
|
|
|
|
public static Kind Of(FileSystemInfo entry)
|
|
{
|
|
if (entry.LinkTarget is not null || entry.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
|
return Kind.Other;
|
|
if (!OperatingSystem.IsLinux())
|
|
return entry is DirectoryInfo ? Kind.Directory : Kind.Regular;
|
|
|
|
var buffer = new byte[256];
|
|
if (Statx(AtFdCwd, entry.FullName, AtSymlinkNoFollow, StatxType, buffer) != 0)
|
|
return Kind.Other;
|
|
var mode = BitConverter.ToUInt16(buffer, 0x1C) & 0xF000;
|
|
return mode switch
|
|
{
|
|
0x8000 => Kind.Regular,
|
|
0x4000 => Kind.Directory,
|
|
_ => Kind.Other
|
|
};
|
|
}
|
|
|
|
private const int AtFdCwd = -100;
|
|
private const int AtSymlinkNoFollow = 0x100;
|
|
private const uint StatxType = 0x1;
|
|
|
|
[DllImport("libc", EntryPoint = "statx", SetLastError = true)]
|
|
private static extern int Statx(int dirfd, [MarshalAs(UnmanagedType.LPUTF8Str)] string path, int flags, uint mask, byte[] buffer);
|
|
}
|
|
|
|
/// <summary>#712: A foreign run that could not take place or not be read.</summary>
|
|
public sealed class ForeignRunException(string message) : Exception(message);
|