* Add standalone Linux bootstrap foundation * Record bootstrap foundation progress * Add single-code Node enrollment * Validate bootstrap release payloads * Add managed WireGuard mesh bootstrap * Exercise repeated systemd installation in CI * Expose systemd smoke failure diagnostics * Fix bootstrap os-release variable collision * Create policy helper directory during install * Capture systemd smoke healthcheck errors * Report Control listener diagnostics in CI * Capture stalled Control process diagnostics * Set service working directories * Run protected CA healthcheck as root * Add local Mesh emergency recovery * Add multi-architecture Linux system matrix * Add provisioning job control plane * Stabilize Debian systemd smoke files * Add node-scoped provisioning job channel * Add provisioning progress state machine * Reconcile expired provisioning jobs * Add provisioning rollback workflow * Add redacted provisioning event history * Add restricted provisioning preflight helper * Persist typed provisioning preflight facts * Add preflight desired state drift detection * Define strict base install schema catalog * Add safe base install plan generation * Add pre-confirmation base install plans * Test base install plan API flow * Authorize confirmed provisioning execution * Execute confirmed timezone provisioning safely * test: stabilize execution grant expiry boundary --------- Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
510 lines
22 KiB
C#
510 lines
22 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text.Json;
|
|
using ServerMonitorManager.Agent;
|
|
using ServerMonitorManager.Core;
|
|
using ServerMonitorManager.Provisioning.Helper;
|
|
using Xunit;
|
|
|
|
namespace ServerMonitorManager.Control.Tests;
|
|
|
|
public sealed class ProvisioningHelperTests
|
|
{
|
|
[Fact]
|
|
public void HelperRejectsEveryActionOutsideFixedAllowlist()
|
|
{
|
|
using var document = JsonDocument.Parse("{}");
|
|
var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
|
|
"1", new string('a', 32), "shell", 1,
|
|
ProvisioningActionCatalog.PreflightModuleHash, document.RootElement.Clone()));
|
|
|
|
Assert.False(response.Success);
|
|
Assert.Equal("action.denied", response.Code);
|
|
Assert.Null(response.Preflight);
|
|
}
|
|
|
|
[Fact]
|
|
public void HelperAcceptsOnlyEmptyPreflightSchemaOne()
|
|
{
|
|
if (!OperatingSystem.IsLinux())
|
|
{
|
|
return;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse("{}");
|
|
var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
|
|
"1", new string('b', 32), "preflight", 1,
|
|
ProvisioningActionCatalog.PreflightModuleHash, document.RootElement.Clone()));
|
|
|
|
Assert.True(response.Success);
|
|
Assert.Equal("preflight.completed", response.Code);
|
|
Assert.NotNull(response.Preflight);
|
|
Assert.NotEmpty(response.Preflight.OperatingSystem);
|
|
Assert.NotEmpty(response.Preflight.Architecture);
|
|
}
|
|
|
|
[Fact]
|
|
public void HelperContractRejectsUnknownJsonMembers()
|
|
{
|
|
const string json = """
|
|
{"protocolVersion":"1","jobId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"actionType":"preflight","schemaVersion":1,
|
|
"moduleHash":"2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b",
|
|
"parameters":{},"command":"id"}
|
|
""";
|
|
|
|
Assert.Throws<JsonException>(() =>
|
|
JsonSerializer.Deserialize(json, SmmJsonContext.Default.ProvisioningHelperRequest));
|
|
}
|
|
|
|
[Fact]
|
|
public void HelperRejectsMissingJobIdWithoutThrowing()
|
|
{
|
|
using var document = JsonDocument.Parse("{}");
|
|
var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
|
|
"1", null!, "preflight", 1,
|
|
ProvisioningActionCatalog.PreflightModuleHash, document.RootElement.Clone()));
|
|
|
|
Assert.False(response.Success);
|
|
Assert.Equal("request.invalid-job", response.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void BaseInstallSchemaRejectsCommandText()
|
|
{
|
|
const string json = """
|
|
{"timezone":"UTC","locale":"en_US.UTF-8","aptUpdate":true,"aptUpgrade":false,
|
|
"packageCatalogVersion":1,"packageGroupIds":["core"],"swapMode":"disabled",
|
|
"swapSizeMiB":null,"vmSwappiness":60,"enableUnattendedUpgrades":true,
|
|
"rebootPolicy":"never","command":"id"}
|
|
""";
|
|
|
|
Assert.Throws<JsonException>(() =>
|
|
JsonSerializer.Deserialize(json, SmmJsonContext.Default.SystemBaseInstallParameters));
|
|
}
|
|
|
|
[Fact]
|
|
public void HelperBuildsDeterministicBaseInstallPlanWithoutCommands()
|
|
{
|
|
var parameters = new SystemBaseInstallParameters(
|
|
"UTC", "en_US.UTF-8", true, false, 1,
|
|
["development", "core"], "disabled", null, 60, true, "never");
|
|
var json = JsonSerializer.SerializeToElement(
|
|
parameters, SmmJsonContext.Default.SystemBaseInstallParameters);
|
|
var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
|
|
"1", new string('c', 32), "system.base-install", 1,
|
|
ProvisioningActionCatalog.SystemBaseInstallModuleHash, json));
|
|
|
|
Assert.True(response.Success);
|
|
Assert.Equal("system.base-install.plan-ready", response.Code);
|
|
Assert.Null(response.Preflight);
|
|
Assert.Equal(
|
|
["ca-certificates", "curl", "jq", "build-essential", "git"],
|
|
response.BaseInstallPlan!.Packages);
|
|
Assert.Equal("never", response.BaseInstallPlan.RebootPolicy);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmedTimezoneOnlyPlanUsesAllowlistedBinaryCreatesBackupAndVerifies()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var grant = SignGrant(authority, signingKey, plan);
|
|
var events = new List<string>();
|
|
var files = new FakeFileSystem(events);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
var process = new FakeProcessRunner(events,
|
|
new(0, "UTC\n", ""),
|
|
new(0, "", ""),
|
|
new(0, "Europe/Berlin\n", ""));
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(plan, grant));
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(result.Changed);
|
|
Assert.True(result.Verified);
|
|
Assert.False(result.RollbackAttempted);
|
|
Assert.Equal("Europe/Berlin", result.ObservedTimezone);
|
|
Assert.Equal(2, files.Writes.Count);
|
|
Assert.True(events.IndexOf("write-backup") < events.IndexOf("process:set-timezone Europe/Berlin"));
|
|
Assert.All(process.Calls, call => Assert.Equal("/usr/bin/timedatectl", call.FileName));
|
|
Assert.Equal(
|
|
[
|
|
"show --property=Timezone --value",
|
|
"set-timezone Europe/Berlin",
|
|
"show --property=Timezone --value"
|
|
],
|
|
process.Calls.Select(call => string.Join(' ', call.Arguments)));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidNoOpTimezoneCompletesOnlyAfterFactualVerification()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("UTC");
|
|
var process = new FakeProcessRunner(
|
|
[], new ProvisioningProcessResult(0, "UTC\n", ""));
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/UTC");
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)));
|
|
|
|
Assert.True(result.Success);
|
|
Assert.False(result.Changed);
|
|
Assert.True(result.Verified);
|
|
Assert.Single(files.Writes);
|
|
Assert.Single(process.Calls);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsumedGrantCannotBeReplayedEvenAfterVerifiedNoOp()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("UTC");
|
|
var grant = SignGrant(authority, signingKey, plan);
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/UTC");
|
|
var firstProcess = new FakeProcessRunner(
|
|
[], new ProvisioningProcessResult(0, "UTC\n", ""));
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, firstProcess, TimeProvider.System,
|
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var first = executor.Execute(CreateExecutionRequest(plan, grant));
|
|
var secondProcess = new FakeProcessRunner([]);
|
|
var replayExecutor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, secondProcess, TimeProvider.System,
|
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
var replay = replayExecutor.Execute(CreateExecutionRequest(plan, grant));
|
|
|
|
Assert.True(first.Success);
|
|
Assert.False(replay.Success);
|
|
Assert.Equal("execution.grant-consumed", replay.Code);
|
|
Assert.Empty(secondProcess.Calls);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ForgedMismatchedAndExpiredGrantsCauseZeroMutation()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var valid = SignGrant(authority, signingKey, plan);
|
|
var invalid = new[]
|
|
{
|
|
valid with { Signature = valid.Signature[..^1] + (valid.Signature[^1] == 'A' ? "B" : "A") },
|
|
valid with { JobId = new string('f', 32) },
|
|
valid with { NodeId = "other-node" },
|
|
valid with { PlanSha256 = new string('0', 64) },
|
|
SignGrant(authority, signingKey, plan, DateTimeOffset.UtcNow.AddMinutes(-10))
|
|
};
|
|
|
|
foreach (var grant in invalid)
|
|
{
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
var process = new FakeProcessRunner([]);
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(plan, grant));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("execution.authorization-denied", result.Code);
|
|
Assert.Empty(files.Writes);
|
|
Assert.Empty(process.Calls);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidGrantForAnotherNodeIsRejectedBeforeMutation()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var otherNodeGrant = SignGrant(authority, signingKey, plan, nodeId: "other-node");
|
|
var localGrant = SignGrant(authority, signingKey, plan);
|
|
var requests = new[]
|
|
{
|
|
CreateExecutionRequest(plan, otherNodeGrant) with
|
|
{
|
|
Execution = new ProvisioningBaseInstallExecutionAuthorization(
|
|
"other-node", plan, otherNodeGrant)
|
|
},
|
|
CreateExecutionRequest(plan, localGrant) with
|
|
{
|
|
Execution = new ProvisioningBaseInstallExecutionAuthorization(
|
|
"other-node", plan, localGrant)
|
|
}
|
|
};
|
|
|
|
foreach (var request in requests)
|
|
{
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
var process = new FakeProcessRunner([]);
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System,
|
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(request);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("execution.authorization-denied", result.Code);
|
|
Assert.Empty(files.Writes);
|
|
Assert.Empty(process.Calls);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UnsupportedBaseInstallFieldsCauseZeroMutation()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var baseline = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var unsupportedPlans = new[]
|
|
{
|
|
baseline with { Locale = "en_US.UTF-8" },
|
|
baseline with { AptUpdate = true },
|
|
baseline with { AptUpgrade = true },
|
|
baseline with { Packages = ["curl"] },
|
|
baseline with { SwapMode = "automatic" },
|
|
baseline with { VmSwappiness = 10 },
|
|
baseline with { EnableUnattendedUpgrades = true },
|
|
baseline with { RebootPolicy = "always" }
|
|
};
|
|
|
|
foreach (var plan in unsupportedPlans)
|
|
{
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
var process = new FakeProcessRunner([]);
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("system.base-install.unsupported-fields", result.Code);
|
|
Assert.Empty(files.Writes);
|
|
Assert.Empty(process.Calls);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AgentExecutionRequestUsesTheControlAuthorizedPlanAndGrant()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
using (var parameters = JsonDocument.Parse("{}"))
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var grant = SignGrant(authority, signingKey, plan);
|
|
var authorization = new ProvisioningBaseInstallExecutionAuthorization("home", plan, grant);
|
|
var job = new ProvisioningJob(
|
|
new string('d', 32), "home", "system.base-install", 1,
|
|
parameters.RootElement.Clone(), ProvisioningJobStates.Running, true,
|
|
"Change timezone", "operator", DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(30),
|
|
DateTimeOffset.UtcNow, null, 3, 40, "execute", null);
|
|
|
|
var request = ProvisioningHelperClient.CreateBaseInstallExecutionRequest(job, authorization);
|
|
|
|
Assert.Same(authorization, request.Execution);
|
|
Assert.Equal(job.Id, request.JobId);
|
|
Assert.Equal(job.ActionType, request.ActionType);
|
|
Assert.Equal(ProvisioningActionCatalog.SystemBaseInstallModuleHash, request.ModuleHash);
|
|
Assert.Equal(JsonValueKind.Object, request.Parameters.ValueKind);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AgentRejectsInconsistentOrUnverifiedHelperSuccess()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("UTC");
|
|
var authorization = new ProvisioningBaseInstallExecutionAuthorization(
|
|
"home", plan, SignGrant(authority, signingKey, plan));
|
|
var unverified = new ProvisioningBaseInstallExecutionResult(
|
|
true, "bad", "bad", false, false, false, false, "UTC");
|
|
var contradictory = new ProvisioningHelperResponse(
|
|
false, "bad", "bad", null, null,
|
|
unverified with { Verified = true });
|
|
|
|
Assert.Throws<InvalidDataException>(() =>
|
|
ProvisioningHelperClient.ValidateBaseInstallExecutionResponse(
|
|
new ProvisioningHelperResponse(true, "bad", "bad", null, null, unverified),
|
|
authorization));
|
|
Assert.Throws<InvalidDataException>(() =>
|
|
ProvisioningHelperClient.ValidateBaseInstallExecutionResponse(
|
|
contradictory, authorization));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SymlinkedBackupPathIsRejectedBeforeTimezoneMutation()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var files = new FakeFileSystem([]);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
files.SymbolicLinks.Add("/var/lib/ochenstarik-server-monitor-manager/provisioning");
|
|
var process = new FakeProcessRunner(
|
|
[], new ProvisioningProcessResult(0, "UTC\n", ""));
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(
|
|
plan, SignGrant(authority, signingKey, plan)));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("backup.unsafe-path", result.Code);
|
|
Assert.Empty(files.Writes);
|
|
Assert.DoesNotContain(process.Calls,
|
|
call => call.Arguments.Count > 0 && call.Arguments[0] == "set-timezone");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VerificationFailureAttemptsAndFactuallyVerifiesRollback()
|
|
{
|
|
using var authority = CreateAuthority(out var signingKey);
|
|
using (signingKey)
|
|
{
|
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
|
var events = new List<string>();
|
|
var files = new FakeFileSystem(events);
|
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
|
var process = new FakeProcessRunner(events,
|
|
new(0, "UTC\n", ""),
|
|
new(0, "", ""),
|
|
new(0, "Etc/Unknown\n", ""),
|
|
new(0, "", ""),
|
|
new(0, "UTC\n", ""));
|
|
var executor = new TimezoneProvisioningExecutor(
|
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
|
|
|
var result = executor.Execute(CreateExecutionRequest(
|
|
plan, SignGrant(authority, signingKey, plan)));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("timezone.verification-failed", result.Code);
|
|
Assert.True(result.RollbackAttempted);
|
|
Assert.True(result.RollbackSucceeded);
|
|
Assert.Equal(
|
|
["set-timezone Europe/Berlin", "set-timezone UTC"],
|
|
process.Calls
|
|
.Where(call => call.Arguments.Count > 0 && call.Arguments[0] == "set-timezone")
|
|
.Select(call => string.Join(' ', call.Arguments)));
|
|
}
|
|
}
|
|
|
|
private static ProvisioningHelperRequest CreateExecutionRequest(
|
|
SystemBaseInstallPlan plan,
|
|
ProvisioningExecutionGrant grant)
|
|
{
|
|
using var document = JsonDocument.Parse("{}");
|
|
return new ProvisioningHelperRequest(
|
|
"1", new string('d', 32), "system.base-install", 1,
|
|
ProvisioningActionCatalog.SystemBaseInstallModuleHash,
|
|
document.RootElement.Clone(),
|
|
new ProvisioningBaseInstallExecutionAuthorization("home", plan, grant));
|
|
}
|
|
|
|
private static SystemBaseInstallPlan CreateTimezoneOnlyPlan(string timezone)
|
|
=> new(timezone, "unchanged", false, false, [], "unchanged", null, 60, false, "never", []);
|
|
|
|
private static X509Certificate2 CreateAuthority(out ECDsa key)
|
|
{
|
|
key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var request = new CertificateRequest("CN=SMM Test CA", key, HashAlgorithmName.SHA256);
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
|
return request.CreateSelfSigned(
|
|
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
|
}
|
|
|
|
private static ProvisioningExecutionGrant SignGrant(
|
|
X509Certificate2 authority,
|
|
ECDsa key,
|
|
SystemBaseInstallPlan plan,
|
|
DateTimeOffset? issuedAt = null,
|
|
string nodeId = "home")
|
|
{
|
|
_ = authority;
|
|
var now = issuedAt ?? DateTimeOffset.UtcNow;
|
|
var grant = new ProvisioningExecutionGrant(
|
|
"1", new string('d', 32), nodeId, "system.base-install", 1,
|
|
ProvisioningExecutionGrantCodec.ComputePlanSha256(plan),
|
|
now.ToUnixTimeSeconds(), now.AddMinutes(2).ToUnixTimeSeconds(),
|
|
new string('e', 32), ProvisioningExecutionGrantCodec.SignatureAlgorithm, string.Empty);
|
|
var signature = key.SignData(
|
|
ProvisioningExecutionGrantCodec.CreateSigningPayload(grant),
|
|
HashAlgorithmName.SHA256,
|
|
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
|
return grant with { Signature = ProvisioningExecutionGrantCodec.EncodeBase64Url(signature) };
|
|
}
|
|
|
|
private sealed class FakeFileSystem(List<string> events) : IProvisioningFileSystem
|
|
{
|
|
public HashSet<string> Files { get; } = new(StringComparer.Ordinal);
|
|
public HashSet<string> SymbolicLinks { get; } = new(StringComparer.Ordinal);
|
|
public List<(string Path, string Content)> Writes { get; } = [];
|
|
|
|
public bool FileExists(string path) => Files.Contains(path);
|
|
|
|
public bool IsSymbolicLink(string path) => SymbolicLinks.Contains(path);
|
|
|
|
public void CreateOwnerOnlyDirectory(string path)
|
|
=> events.Add($"mkdir:{path}");
|
|
|
|
public void WriteOwnerOnlyFile(string path, string content)
|
|
{
|
|
if (Writes.Any(write => string.Equals(write.Path, path, StringComparison.Ordinal)))
|
|
{
|
|
throw new IOException("Owner-only file already exists.");
|
|
}
|
|
Writes.Add((path, content));
|
|
events.Add("write-backup");
|
|
}
|
|
}
|
|
|
|
private sealed class FakeProcessRunner(
|
|
List<string> events,
|
|
params ProvisioningProcessResult[] results) : IProvisioningProcessRunner
|
|
{
|
|
private readonly Queue<ProvisioningProcessResult> _results = new(results);
|
|
public List<(string FileName, IReadOnlyList<string> Arguments)> Calls { get; } = [];
|
|
|
|
public ProvisioningProcessResult Run(string fileName, IReadOnlyList<string> arguments)
|
|
{
|
|
Calls.Add((fileName, arguments.ToArray()));
|
|
events.Add($"process:{string.Join(' ', arguments)}");
|
|
return _results.Count == 0
|
|
? throw new InvalidOperationException("Unexpected process call.")
|
|
: _results.Dequeue();
|
|
}
|
|
}
|
|
}
|