Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
e03e48a
Filesystem lock support detection and fallbacks
rhysparry Mar 17, 2026
c822f2f
Promote CachedDriveInfo to top-level internal type with injectable ac…
rhysparry Mar 18, 2026
19b632e
Promote MountedDrives to top-level internal type
rhysparry Mar 18, 2026
b2f47ed
Add injectable overload of GetLockDirectory for testing
rhysparry Mar 18, 2026
a58fc1f
Extract ResolveLockOptions from Isolation.PrepareLockOptions
rhysparry Mar 18, 2026
a090218
Add LockDirectoryFixture unit tests for GetLockDirectory and CachedDr…
rhysparry Mar 18, 2026
5c2b544
Add ResolveLockOptions unit tests to IsolationFixture
rhysparry Mar 18, 2026
b64f672
Replace call-count lock delegates with FakeLockService in LockDirecto…
rhysparry Mar 18, 2026
ebf43ce
Only use temp directory when it offers better lock support than the c…
rhysparry Mar 18, 2026
8ba435a
Make directory creation injectable in DetectLockSupport to enable her…
rhysparry Mar 19, 2026
865b65a
Replace coupled delegates with IFileLockService interface
rhysparry Mar 19, 2026
30b5a4d
Add /dev/shm as a fallback temp directory
rhysparry Mar 19, 2026
27f84c8
Extract temporary directory fallback into injectable ITemporaryDirect…
rhysparry Mar 19, 2026
f98d20c
Various cosmetic fixes
rhysparry Mar 19, 2026
f8ba233
Move temp directory detection into IFileLockService
rhysparry Mar 19, 2026
4dd2d10
Always apply the namespace to the temp dir
rhysparry Mar 19, 2026
496ce77
Make warnings during fallback testable
rhysparry Mar 19, 2026
991d35f
Move windows temp path detection
rhysparry Mar 19, 2026
aeba042
Robustify MountedDrives.GetAssociatedDrive with symlink resolution an…
rhysparry Mar 19, 2026
fdeb537
Fix Group D tests to use FakePathResolutionService.PassThrough
rhysparry Mar 19, 2026
6f0b00a
Refactor IPathResolutionService to minimal BCL pass-throughs; move lo…
rhysparry Mar 19, 2026
aeb71ef
Handle GetFullPath exceptions in ResolvePath so one bad path cannot b…
rhysparry Mar 19, 2026
a333181
Use Path.Join instead of Path.Combine
rhysparry Mar 19, 2026
4bccf2a
Sort test groups
rhysparry Mar 19, 2026
81bf02e
Fix LockOptionsFixture after lock directory path fallback change
rhysparry Mar 19, 2026
e739ea1
Detect candidate lock support before checking temp directories
rhysparry Mar 19, 2026
07bcdfe
Handle UnauthorizedAccessException in ResolvePath ancestor walk
rhysparry Mar 20, 2026
6f25990
Use Path.GetTempPath() as TentacleHome in LockOptionsFixture
rhysparry Mar 20, 2026
49dc9dd
Fix DetectLockSupport probe always returning Unsupported
rhysparry Mar 20, 2026
5c702ad
Update LockDirectoryFixture tests for nullable LockCapability
rhysparry Mar 23, 2026
78c9264
Move DetectLockSupport and lock detection logic into LockDirectory
rhysparry Mar 23, 2026
4412653
Fix issue where candidate path ends in `/` or `\`
rhysparry Mar 24, 2026
af28598
Null check for lock file deletion in tests
rhysparry Mar 24, 2026
48e28a6
Cache mounted drives
rhysparry Mar 24, 2026
499e4c4
Remove user configurable isolation promotion
rhysparry Mar 24, 2026
cc69ef2
Introduce factories for creating options
rhysparry Mar 24, 2026
eab60ec
Update tests to use lock options factories
rhysparry Mar 24, 2026
5428a2b
Remove ResolveLockOptions from Isolation
rhysparry Mar 24, 2026
55446e3
Re-organise tests
rhysparry Mar 24, 2026
03a467a
Don't fully qualify Timeout
rhysparry Mar 25, 2026
55dc6a2
Introduce LockDirectoryFactory
rhysparry Mar 25, 2026
469eef9
Update LockDirectoryFixture to use Factory
rhysparry Mar 25, 2026
bc752c2
Fully remove detection from LockDirectory
rhysparry Mar 25, 2026
b82f284
Refactor mounted drives/cached drive info
rhysparry Mar 25, 2026
fb1d453
Move pipeline builder into a separate class
rhysparry Mar 25, 2026
265b15c
Use Autofac registration for script isolation
rhysparry Mar 25, 2026
0dd7351
Rename some things to improve clarity
rhysparry Mar 25, 2026
e332f6c
Split temp directory fallback from file lock service
rhysparry Mar 25, 2026
9eeeb25
Improve directory creation
rhysparry Mar 25, 2026
da273a3
Fix mount matching when candidate is mount point
rhysparry Mar 25, 2026
2583106
Fix nested mount tests to run on Windows
rhysparry Mar 25, 2026
4a93000
Refactor lock type determination logic for improved clarity
rhysparry Apr 2, 2026
976f90d
Rename LockOptionsFactory to LockOptionsResolver for semantic clarity
rhysparry Apr 2, 2026
93ef577
Rename RequestedLockOptionsFactory.CreateOrNull to CreateFromIsolatio…
rhysparry Apr 2, 2026
c712a08
Rename PrepareLockOptions to DetermineLockOptionsToEnforceBasedOnIsol…
rhysparry Apr 2, 2026
1b4d434
Add lock detection result tracking and script isolation alert service…
rhysparry Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions source/Calamari.Common/CalamariFlavourProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ protected virtual int Run(string[] args)
}
#endif

using var _ = Isolation.Enforce(options.ScriptIsolation);
var isolation = container.Resolve<IScriptIsolationEnforcer>();
using var _ = isolation.Enforce(options.ScriptIsolation);
return ResolveAndExecuteCommand(container, options);
}
catch (Exception ex)
Expand Down Expand Up @@ -121,6 +122,7 @@ protected virtual void ConfigureContainer(ContainerBuilder builder, CommonOption

builder.RegisterModule<VariablesModule>();
builder.RegisterModule<SubstitutionsModule>();
builder.RegisterModule<ScriptIsolationModule>();

var assemblies = GetAllAssembliesToRegister().ToArray();

Expand Down Expand Up @@ -157,4 +159,4 @@ protected virtual IEnumerable<Assembly> GetAllAssembliesToRegister()
yield return typeof(CalamariFlavourProgram).Assembly; // Calamari.Common
}
}
}
}
6 changes: 4 additions & 2 deletions source/Calamari.Common/CalamariFlavourProgramAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ protected virtual void ConfigureContainer(ContainerBuilder builder, CommonOption

builder.RegisterModule<VariablesModule>();
builder.RegisterModule<SubstitutionsModule>();
builder.RegisterModule<ScriptIsolationModule>();

var assemblies = GetAllAssembliesToRegister().ToArray();

Expand Down Expand Up @@ -145,7 +146,8 @@ protected async Task<int> Run(string[] args)
}
#endif

await using var _ = await Isolation.EnforceAsync(options.ScriptIsolation, CancellationToken.None);
var isolation = container.Resolve<IScriptIsolationEnforcer>();
await using var _ = await isolation.EnforceAsync(options.ScriptIsolation, CancellationToken.None);
await ResolveAndExecuteCommand(container, options);
return 0;
}
Expand Down Expand Up @@ -186,4 +188,4 @@ Task ResolveAndExecuteCommand(ILifetimeScope container, CommonOptions options)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

sealed record CachedDriveInfo(
DirectoryInfo RootDirectory,
string Format,
DriveType DriveType,
LockCapability? DetectedLockSupport = null
)
{
const string UnknownFormat = "unknown";

public static CachedDriveInfo From(DriveInfo driveInfo)
{
// These should not throw
var rootDirectory = driveInfo.RootDirectory;
var driveType = driveInfo.DriveType;
try
{
var format = driveInfo.DriveFormat; // May throw
return new CachedDriveInfo(rootDirectory, format, driveType);
}
catch
{
// If it is throwing an error here, don't trust it for locking
return new CachedDriveInfo(
RootDirectory: rootDirectory,
Format: UnknownFormat,
DriveType: driveType,
DetectedLockSupport: LockCapability.Unsupported
);
}
}

public LockCapability? LockSupport
{
get
{
if (DetectedLockSupport is not null)
{
return DetectedLockSupport.Value;
}

switch (DriveType)
{
case DriveType.Network:
return null; // Default to assuming network is unknown
default: // Explicitly falling through to format inspection
break;
}

switch (Format.ToLowerInvariant())
{
case "apfs":
case "btrfs":
case "ext4":
case "hfs+":
case "ntfs":
case "tmpfs":
case "xfs":
case "zfs":
// We trust that these filesystems fully support file locking and will skip
// testing these filesystems for compatibility.
return LockCapability.Supported;
default:
return null;
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

/// <summary>
/// The production implementation of <see cref="IPathResolutionService"/>.
/// Each member is a direct, thin delegate to a BCL primitive — all
/// resolution <em>logic</em> lives in
/// <see cref="PathResolutionServiceExtensions.ResolvePath"/>.
/// </summary>
sealed class DefaultPathResolutionService : IPathResolutionService
{
DefaultPathResolutionService() { }

public static readonly DefaultPathResolutionService Instance = new();

/// <inheritdoc/>
/// <remarks>Delegates to <see cref="Path.GetFullPath(string)"/>.</remarks>
public string GetFullPath(string path) => Path.GetFullPath(path);

/// <inheritdoc/>
/// <remarks>
/// Delegates to <see cref="FileInfo.ResolveLinkTarget"/> with
/// <c>returnFinalTarget: true</c> so that symlink chains are followed
/// all the way to their ultimate target.
/// </remarks>
public FileSystemInfo? ResolveLinkTarget(string path)
=> new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);

/// <inheritdoc/>
public StringComparison PathComparison =>
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
using System;
using System.IO;
using System.Threading.Tasks;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

public static class FileLock
{
public static ILockHandle Acquire(LockOptions lockOptions)
{
var fileShareMode = GetFileShareMode(lockOptions.Type);
try
{
var fileStream = lockOptions.LockFile.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite, fileShareMode);
return new LockHandle(fileStream);
return lockOptions.LockFile.Open(lockOptions.Type);
}
catch (IOException e) when (IsFileLocked(e))
{
Expand Down Expand Up @@ -43,27 +40,4 @@ static bool IsFileLocked(IOException ioException)

return false;
}

static FileShare GetFileShareMode(LockType isolationLevel)
{
return isolationLevel switch
{
LockType.Exclusive => FileShare.None,
LockType.Shared => FileShare.ReadWrite,
_ => throw new ArgumentOutOfRangeException(nameof(isolationLevel), isolationLevel, null)
};
}

sealed class LockHandle(FileStream fileStream) : ILockHandle
{
public void Dispose()
{
fileStream.Dispose();
}

public async ValueTask DisposeAsync()
{
await fileStream.DisposeAsync();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

/// <summary>
/// The real filesystem implementation of <see cref="IFileLockService"/>.
/// Uses <see cref="FileLock.Acquire"/> for lock acquisition and
/// <see cref="Directory.CreateDirectory(string)"/> for directory creation.
/// </summary>
sealed class FileLockService : IFileLockService
{
FileLockService() { }

public static readonly IFileLockService Instance = new FileLockService();

public void CreateDirectory(DirectoryInfo directory) => directory.Create();

public ILockHandle AcquireLock(LockOptions options) => FileLock.Acquire(options);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

interface ICachedDriveInfoProvider
{
CachedDriveInfo GetAssociatedDrive(string path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

/// <summary>
/// Abstracts the filesystem operations required to probe lock support on a drive.
/// Separating these from the static implementations allows hermetic unit testing
/// without touching the real filesystem.
/// </summary>
interface IFileLockService
{
/// <summary>Ensures the given directory path exists.</summary>
/// <remarks>This allows a test implementation to not actually create the directory.</remarks>
void CreateDirectory(DirectoryInfo directory);

/// <summary>Attempts to acquire a lock described by <paramref name="options"/>.</summary>
/// <exception cref="LockRejectedException">
/// Thrown when the lock cannot be acquired due to a conflicting hold.
/// </exception>
/// <exception cref="System.IO.IOException">
/// Thrown when the filesystem does not support the requested lock type.
/// </exception>
ILockHandle AcquireLock(LockOptions options);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

public interface ILockDirectoryFactory
{
LockDirectory Create(DirectoryInfo preferredLockDirectory);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

interface IMountedDrivesProvider
{
MountedDrives GetMountedDrives();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

/// <summary>
/// Minimal abstraction over the BCL path and symlink primitives used when
/// resolving a path to its canonical form for drive-root matching.
/// Keeping the interface thin allows the resolution <em>logic</em> — housed
/// in <see cref="PathResolutionServiceExtensions.ResolvePath"/> — to be
/// exercised independently of real filesystem state.
/// </summary>
interface IPathResolutionService
{
/// <summary>
/// Returns the absolute, normalised form of <paramref name="path"/>
/// (equivalent to <see cref="Path.GetFullPath(string)"/>).
/// This operation is purely lexical: no filesystem access, no symlink
/// resolution.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">
/// <paramref name="path"/> is zero-length, contains only white space (Windows),
/// contains invalid path characters, or the system could not retrieve the
/// absolute path.
/// </exception>
/// <exception cref="System.Security.SecurityException">
/// The caller does not have the required permissions.
/// </exception>
/// <exception cref="NotSupportedException">
/// .NET Framework only: <paramref name="path"/> contains a colon that is not
/// part of a volume identifier.
/// </exception>
/// <exception cref="PathTooLongException">
/// The specified path exceeds the system-defined maximum length.
/// </exception>
string GetFullPath(string path);

/// <summary>
/// Resolves the final symlink target of <paramref name="path"/>.
/// <list type="bullet">
/// <item><description>
/// Returns <c>null</c> if the path exists but is <em>not</em> a symlink.
/// </description></item>
/// <item><description>
/// Returns a <see cref="FileSystemInfo"/> pointing at the real target
/// when the path is a symlink (following chains to the final target).
/// </description></item>
/// <item><description>
/// Throws <see cref="FileNotFoundException"/>,
/// <see cref="DirectoryNotFoundException"/>, or <see cref="IOException"/>
/// when the path does not exist on disk.
/// </description></item>
/// </list>
/// </summary>
FileSystemInfo? ResolveLinkTarget(string path);

/// <summary>
/// The <see cref="StringComparison"/> appropriate for comparing paths on the
/// host filesystem. Typically <see cref="StringComparison.OrdinalIgnoreCase"/>
/// on Windows and macOS, and <see cref="StringComparison.Ordinal"/> on Linux.
/// </summary>
StringComparison PathComparison { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Calamari.Common.Plumbing.Commands;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

public interface IScriptIsolationEnforcer
{
ILockHandle Enforce(CommonOptions.ScriptIsolationOptions scriptIsolationOptions);

Task<ILockHandle> EnforceAsync(
CommonOptions.ScriptIsolationOptions scriptIsolationOptions,
CancellationToken cancellationToken
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Calamari.Common.Features.Processes.ScriptIsolation;

interface ITemporaryDirectoryFallbackProvider
{
IEnumerable<LockDirectoryFallback> GetFallbackCandidates(DirectoryInfo preferredDirectory);
}
Loading