diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs
index b9be53d91..bd960517b 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs
@@ -3,6 +3,7 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Workflows.Artifacts;
using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
using CodeSpace.Core.Settings;
using CodeSpace.Messages.Constants;
@@ -22,11 +23,13 @@ public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness
internal const string DefaultProfileStableName = "codespace-agent-run-log-default";
private readonly CodeSpaceDbContext _db;
private readonly TimeProvider _clock;
+ private readonly IStorageProviderModuleCatalog _modules;
- public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock)
+ public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock, IStorageProviderModuleCatalog modules)
{
_db = db;
_clock = clock;
+ _modules = modules;
}
public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
@@ -53,7 +56,7 @@ public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancell
return;
}
- var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow());
+ var profile = BuildProfile(teamId, canonicalConfig, NamespaceFingerprint(canonicalConfig), _clock.GetUtcNow());
_db.StorageProfile.Add(profile);
var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow());
_db.StorageRoute.Add(route);
@@ -71,7 +74,14 @@ public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancell
}
}
- private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, DateTimeOffset now)
+ /// The same derivation admission uses, so this bootstrap's revision names its namespace exactly as a Settings-created one would.
+ private string NamespaceFingerprint(string canonicalConfig)
+ {
+ using var document = JsonDocument.Parse(canonicalConfig);
+ return StorageProfileRules.NamespaceFingerprint(_modules.Require(LocalRwxArtifactStorageDriverFactory.TypeKey), document.RootElement);
+ }
+
+ private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, string namespaceFingerprint, DateTimeOffset now)
{
var profile = new StorageProfile
{
@@ -79,12 +89,11 @@ private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig,
State = StorageProfileState.Active, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
};
- using var document = JsonDocument.Parse(canonicalConfig);
profile.Revisions.Add(new StorageProfileRevision
{
Id = Guid.NewGuid(), TeamId = teamId, StorageProfileId = profile.Id, Revision = 1,
ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey, NonSecretConfigJson = canonicalConfig,
- CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, document.RootElement),
+ CredentialRef = null, NamespaceFingerprint = namespaceFingerprint,
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
});
return profile;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileRules.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileRules.cs
index 7fd406406..58cf6ef95 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileRules.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileRules.cs
@@ -44,6 +44,53 @@ public static string NamespaceFingerprint(string providerTypeKey, JsonElement na
return "sha256:" + Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();
}
+ ///
+ /// The identity of the PHYSICAL namespace a configuration reaches: the provider's own canonical projection of it,
+ /// hashed. The one derivation behind the fingerprint a revision stores and behind the retention reaper's later
+ /// comparison of two revisions' objects (), so the two cannot disagree about what
+ /// "the same namespace" means.
+ ///
+ public static string NamespaceFingerprint(IStorageProviderModule module, JsonElement nonSecretConfiguration)
+ {
+ var namespaceConfig = module.GetNamespaceConfiguration(nonSecretConfiguration);
+ if (namespaceConfig.ValueKind != JsonValueKind.Object)
+ throw new InvalidOperationException($"Storage provider '{module.TypeKey}' namespace configuration projection must be a JSON object.");
+
+ return NamespaceFingerprint(module.TypeKey, namespaceConfig);
+ }
+
+ ///
+ /// WHICH physical object a STORED placement's key reaches, derived now from its revision's configuration rather
+ /// than read from the fingerprint the revision stored, or null when it cannot be derived: the provider is not
+ /// registered in this build, the configuration no longer parses, or the provider cannot place the key.
+ ///
+ /// A provider that can name the object itself () is asked for it,
+ /// because for a filesystem a link beneath the root can carry a key out of the namespace its root names. Every other
+ /// provider's object is its namespace and the key together.
+ ///
+ /// Null is "unknown", never "different". A caller comparing objects must treat it as possibly the same object
+ /// as anything at that key, which is why every failure lands here instead of propagating: the comparison this feeds
+ /// decides whether bytes are deleted, and there only a kept byte is a safe mistake.
+ ///
+ public static string? TryObjectIdentity(IStorageProviderModuleCatalog catalog, string providerTypeKey, string nonSecretConfigJson, string objectKey)
+ {
+ try
+ {
+ var module = catalog.Get(providerTypeKey);
+ if (module == null) return null;
+
+ using var configuration = JsonDocument.Parse(nonSecretConfigJson);
+ return ObjectIdentity(module, configuration.RootElement, objectKey);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static string ObjectIdentity(IStorageProviderModule module, JsonElement configuration, string objectKey) =>
+ module is IStorageProviderPhysicalObjects physical ? physical.PhysicalObject(configuration, objectKey) : $"{NamespaceFingerprint(module, configuration)}/{objectKey}";
+
public static bool TryParseCredentialRef(string? value, out StorageProfileCredentialReference reference)
{
reference = default;
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileService.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileService.cs
index 45b99e1bc..5ee04d154 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileService.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Profiles/StorageProfileService.cs
@@ -269,10 +269,10 @@ private async Task PrepareRevisionAsync(Guid teamId, string pr
ExecuteRule(() => module.EnsureConfigurationReadable(canonicalElement));
var normalizedCredentialRef = await ValidateCredentialAsync(teamId, module.TypeKey, credentialRef, cancellationToken).ConfigureAwait(false);
- var namespaceConfig = module.GetNamespaceConfiguration(canonicalElement);
- if (namespaceConfig.ValueKind != JsonValueKind.Object)
- throw new InvalidOperationException($"Storage provider '{module.TypeKey}' namespace configuration projection must be a JSON object.");
- var fingerprint = ExecuteRule(() => StorageProfileRules.NamespaceFingerprint(module.TypeKey, namespaceConfig));
+
+ // The fingerprint names the PHYSICAL namespace the provider says this configuration reaches, not its text, so two
+ // spellings of one destination store one fingerprint. The configuration itself is persisted as written.
+ var fingerprint = ExecuteRule(() => StorageProfileRules.NamespaceFingerprint(module, canonicalElement));
return new PreparedRevision(module.TypeKey, canonicalConfig, normalizedCredentialRef, fingerprint);
}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageProviderModule.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageProviderModule.cs
index a76d3d46f..db28da15c 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageProviderModule.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageProviderModule.cs
@@ -120,6 +120,12 @@ public sealed class AliyunOssStorageProviderModule : IStorageProviderModule, ISt
///
public void EnsureConfigurationReadable(JsonElement nonSecretConfiguration) => AliyunOssTarget.Parse(nonSecretConfiguration);
+ ///
+ /// The bucket and prefix the configuration reaches, read through the same parser activation uses: a configuration
+ /// the driver could never open has no namespace to name, so it is refused rather than given an identity.
+ ///
+ public JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => AliyunOssTarget.Parse(nonSecretConfiguration).NamespaceConfiguration();
+
private static JsonElement ParseSchema(string json)
{
using var document = JsonDocument.Parse(json);
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssTarget.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssTarget.cs
index d9e392ec9..b0aaf4817 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssTarget.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/AliyunOss/AliyunOssTarget.cs
@@ -44,6 +44,33 @@ public static AliyunOssTarget Parse(JsonElement configuration)
};
}
+ ///
+ /// The bucket namespace this target addresses, spelled one way: the canonical configuration every spelling that
+ /// reaches the same bucket and prefix projects to.
+ ///
+ /// The host is only a ROUTE to a bucket: the public, -internal VPC and accelerate hosts, with or
+ /// without the https:// scheme, all reach the bucket of one region. A custom domain or proxy cannot say
+ /// which bucket it fronts, and an identity that cannot tell two routes apart must merge them rather than split
+ /// them; OSS bucket names are unique service-wide and every request is signed for the bucket's region, so the one
+ /// OSS bucket any route can reach is that region's bucket of that name. So the identity names the region's public
+ /// host whatever host was written. An empty key prefix is the bucket root and is spelled by leaving it out. A
+ /// configuration already written this way projects to exactly itself, which keeps the fingerprint every such
+ /// revision already stored.
+ ///
+ public JsonElement NamespaceConfiguration()
+ {
+ var namespaceConfiguration = new SortedDictionary(StringComparer.Ordinal)
+ {
+ ["bucket"] = Bucket,
+ ["endpoint"] = $"{ServiceHostPrefix}{Region}{ServiceDomainSuffix}",
+ };
+
+ if (KeyPrefix.Length > 0) namespaceConfiguration["keyPrefix"] = KeyPrefix;
+
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(namespaceConfiguration));
+ return document.RootElement.Clone();
+ }
+
///
/// Projects a caller's object key onto a bucket key, refusing anything that could escape the profile's prefix.
/// Traversal and empty segments are rejected rather than normalized so two different caller keys can never
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderModule.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderModule.cs
index 8f13be9db..e5d1340a7 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderModule.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderModule.cs
@@ -22,9 +22,18 @@ public interface IStorageProviderModule
JsonElement SecretSchema { get; }
///
- /// Project the non-secret configuration fields that identify the provider namespace. The control plane canonicalizes
- /// and hashes this server-owned projection; clients can never submit a fingerprint. Providers with operational
- /// tuning fields should override the conservative default so those fields do not create a new namespace identity.
+ /// Project the non-secret configuration onto the PHYSICAL namespace it reaches, spelled one way. The control plane
+ /// canonicalizes and hashes this server-owned projection into the revision's fingerprint (clients can never submit
+ /// one), and the retention reaper re-derives it from stored revisions to decide whether one object key is one
+ /// physical object across teams — unless the provider names that object itself
+ /// (), because its keys can leave the namespace its root names.
+ ///
+ /// So two configurations that reach the same namespace MUST project identically — a root with and without a
+ /// trailing separator, a host and its alias — and where a provider cannot tell, it projects them together: a
+ /// merged identity only keeps bytes, a split one lets one team's purge delete another's. A configuration that
+ /// reaches no namespace at all throws. Operational tuning fields belong outside the projection. The default is the
+ /// raw configuration, which is right only for a provider whose configuration has exactly one spelling; every
+ /// shipped module overrides it.
///
JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => nonSecretConfiguration.Clone();
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderPhysicalObjects.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderPhysicalObjects.cs
new file mode 100644
index 000000000..daa801bdb
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/IStorageProviderPhysicalObjects.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+
+namespace CodeSpace.Core.Services.Workflows.Artifacts.Providers;
+
+///
+/// A provider that can name the PHYSICAL object one of its keys reaches, because its namespace alone cannot: a
+/// filesystem destination resolves every key through the operating system, which follows a link anywhere beneath the
+/// configured root, so two roots that are different directories can still lead one key to one file.
+///
+/// A sibling of (Rule 7): for an object store the bucket and prefix the
+/// namespace projection names, plus the key, already ARE the object, and such a provider must not have to say so.
+///
+/// The retention reaper asks this at sweep time, for every placement that shares a key with the one it is about
+/// to delete, because object keys carry no team segment and the answer decides whether a delete removes another team's
+/// bytes. So two keys that reach one object MUST answer identically, and an object that cannot be established throws:
+/// the caller reads a throw as "possibly the same as anything", which only ever keeps a byte.
+///
+public interface IStorageProviderPhysicalObjects
+{
+ /// The object a delete of would remove under this configuration, spelled one way, as it stands now.
+ string PhysicalObject(JsonElement nonSecretConfiguration, string objectKey);
+}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/Legacy/LocalLegacyStorageProviderModule.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/Legacy/LocalLegacyStorageProviderModule.cs
index 761c51d61..8dfba3fc2 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/Legacy/LocalLegacyStorageProviderModule.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/Legacy/LocalLegacyStorageProviderModule.cs
@@ -52,6 +52,9 @@ public sealed class LocalLegacyStorageProviderModule : IStorageProviderModule, I
public StorageProviderCapabilities Capabilities => StorageProviderCapabilities.HealthProbe | StorageProviderCapabilities.StreamingRead;
public Type FactoryType => typeof(LocalLegacyArtifactStorageDriverFactory);
+ /// The directory the root reaches, not the text that names it: see .
+ public JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => LocalRootPath.NamespaceConfiguration(nonSecretConfiguration);
+
public string? ResolveLegacyObjectKey(JsonElement nonSecretConfiguration, string sha256, string recordedLocator)
{
var key = LegacyLocalObjectKeys.For(sha256);
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRootPath.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRootPath.cs
new file mode 100644
index 000000000..8724a5eef
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRootPath.cs
@@ -0,0 +1,159 @@
+using System.Text;
+using System.Text.Json;
+
+namespace CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+
+///
+/// The physical identity of a filesystem namespace: the one directory a configured rootPath actually reaches,
+/// spelled one way.
+///
+/// Every local driver opens its root with and lets the operating system
+/// follow whatever links lie along it, so R, R/, R/., R//, a relative spelling of
+/// R, a symbolic link to R and — on a case-insensitive filesystem — r are all one directory.
+/// Object keys carry no team segment, so two profiles naming that directory share every object they both store; a
+/// comparison that saw two spellings as two namespaces would let one team's purge delete the other team's bytes.
+///
+/// Where the comparison is unsure it errs towards SAME: the folding below may merge directories a case-sensitive
+/// volume keeps apart, and that only ever keeps bytes. Splitting one directory into two is the failure that loses
+/// them.
+///
+/// Links are resolved in the order the operating system resolves them — one component at a time, a link's target
+/// spliced in where the link stood, and .. applied to the directory reached so far rather than to the text — so
+/// L -> a/.. beside a -> R/objects is R, as it is to open(2), not the directory
+/// holding L. A root is also not the only place a link can stand: resolves the whole
+/// path a driver opens for one key.
+///
+internal static class LocalRootPath
+{
+ /// The configuration property every local provider names its root with.
+ public const string Property = "rootPath";
+
+ /// The same bound realpath(3) uses; a longer chain is a loop or an attack, and neither has an identity.
+ private const int MaxSymbolicLinkHops = 40;
+
+ /// The provider's namespace projection: {"rootPath": <canonical>}, so a canonical configuration projects to itself.
+ public static JsonElement NamespaceConfiguration(JsonElement nonSecretConfiguration)
+ {
+ var canonical = Canonicalize(Read(nonSecretConfiguration));
+
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(new Dictionary { [Property] = canonical }));
+ return document.RootElement.Clone();
+ }
+
+ /// Absolute, lexically normalised, links resolved where they exist, then folded as this platform's filesystem compares names.
+ public static string Canonicalize(string rootPath) => FoldForPlatform(Physical(rootPath));
+
+ ///
+ /// The file a local driver removes when it deletes : every directory from the root down
+ /// to it resolved as the operating system resolves them, the file's own name as written.
+ ///
+ /// Every directory, because a link beneath the root redirects a key exactly as a link in the root does, so a
+ /// collector that owns its root outright can still reach a victim's file through objects or any directory
+ /// below it. Not the name itself, because a delete unlinks the name it is given: a key that is a link removes the
+ /// link and leaves its target alone.
+ ///
+ public static string ObjectPath(JsonElement nonSecretConfiguration, string objectsDirectory, string objectKey)
+ {
+ var segments = KeySegments(objectKey);
+ var directory = Physical(Path.Combine([Read(nonSecretConfiguration), objectsDirectory, .. segments[..^1]]));
+
+ return FoldForPlatform(Path.Combine(directory, segments[^1]));
+ }
+
+ private static string Physical(string path) => ResolveSymbolicLinks(Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)));
+
+ /// The key exactly as a local driver splits it, refused exactly where a driver refuses it: a key no driver opens names no object.
+ private static string[] KeySegments(string objectKey)
+ {
+ if (string.IsNullOrWhiteSpace(objectKey) || objectKey.Contains('\0') || Path.IsPathRooted(objectKey))
+ throw new ArgumentException("An object key must be a non-empty relative key.", nameof(objectKey));
+
+ var segments = objectKey.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+
+ if (segments.Length == 0 || segments.Any(segment => segment is "." or ".."))
+ throw new ArgumentException($"Object key '{objectKey}' cannot contain traversal segments.", nameof(objectKey));
+
+ return segments;
+ }
+
+ private static string Read(JsonElement configuration)
+ {
+ if (configuration.ValueKind == JsonValueKind.Object && configuration.TryGetProperty(Property, out var root)
+ && root.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(root.GetString()))
+ return root.GetString()!;
+
+ throw new ArgumentException($"A local storage namespace requires a non-empty string '{Property}'.", nameof(configuration));
+ }
+
+ ///
+ /// with every existing symbolic link along it replaced by what it points at, the way
+ /// realpath(3) does it: the path is walked one component at a time from the root, a link's target is spliced
+ /// in where the link stood and walked in its turn, and .. steps up from the directory reached so far — which
+ /// holds no links by then, so stepping up from it is exactly what the operating system does.
+ ///
+ /// Components that do not exist yet stay as written: there is nothing there to follow, and the directory a
+ /// later write creates is exactly that spelling beneath the part already resolved.
+ ///
+ private static string ResolveSymbolicLinks(string fullPath)
+ {
+ var resolved = Path.GetPathRoot(fullPath)!;
+ var pending = new Stack();
+ var hops = 0;
+
+ PushComponents(pending, fullPath);
+
+ while (pending.TryPop(out var component))
+ {
+ var target = component is "." or ".." ? null : LinkTarget(Path.Combine(resolved, component));
+
+ if (target == null)
+ {
+ resolved = Step(resolved, component);
+ continue;
+ }
+
+ if (++hops > MaxSymbolicLinkHops)
+ throw new ArgumentException($"'{Property}' '{fullPath}' passes through more than {MaxSymbolicLinkHops} symbolic links, so the directory it names cannot be established.");
+
+ if (Path.IsPathRooted(target)) resolved = Path.GetPathRoot(target)!;
+
+ PushComponents(pending, target);
+ }
+
+ return resolved;
+ }
+
+ /// One component down from a directory with no links left in it — or, for .., one up.
+ private static string Step(string resolved, string component) => component switch
+ {
+ "." => resolved,
+ ".." => Path.GetDirectoryName(resolved) ?? resolved,
+ _ => Path.Combine(resolved, component),
+ };
+
+ /// Queues the components of below its root so that its first component is walked next.
+ private static void PushComponents(Stack pending, string path)
+ {
+ var components = path[Path.GetPathRoot(path)!.Length..].Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
+
+ for (var index = components.Length - 1; index >= 0; index--) pending.Push(components[index]);
+ }
+
+ ///
+ /// A component this process cannot inspect is treated as no link. Resolving it is not possible from here, and
+ /// whatever this process cannot reach it cannot write to or delete from either.
+ ///
+ private static string? LinkTarget(string path)
+ {
+ try { return new FileInfo(path).LinkTarget; }
+ catch (IOException) { return null; }
+ catch (UnauthorizedAccessException) { return null; }
+ }
+
+ ///
+ /// Windows and macOS filesystems compare names case-insensitively by default, and macOS also ignores Unicode
+ /// normalisation, so on those platforms both are folded. Linux compares bytes and keeps the path as resolved.
+ ///
+ private static string FoldForPlatform(string path) =>
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? path.Normalize(NormalizationForm.FormC).ToLowerInvariant() : path;
+}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRwxStorageProviderModule.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRwxStorageProviderModule.cs
index 20f5e6808..804a9dd29 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRwxStorageProviderModule.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Providers/Local/LocalRwxStorageProviderModule.cs
@@ -7,8 +7,14 @@ namespace CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
/// driver or activate it through : that store continues to receive the convention-registered
/// exactly as before until the profile/runtime cutover lands.
///
-public sealed class LocalRwxStorageProviderModule : IStorageProviderModule, IStorageProviderTeamNamespace
+public sealed class LocalRwxStorageProviderModule : IStorageProviderModule, IStorageProviderTeamNamespace, IStorageProviderPhysicalObjects
{
+ ///
+ /// The directory beneath the root that keeps every object in
+ /// (TryResolveObjectPath); restated here, and pinned against the real driver by a unit test.
+ ///
+ private const string ObjectsDirectory = "objects";
+
private static readonly JsonElement Config = ParseSchema("""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -53,6 +59,12 @@ public sealed class LocalRwxStorageProviderModule : IStorageProviderModule, ISto
| StorageProviderCapabilities.HealthProbe;
public Type FactoryType => typeof(LocalRwxArtifactStorageDriverFactory);
+ /// The directory the root reaches, not the text that names it: see .
+ public JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => LocalRootPath.NamespaceConfiguration(nonSecretConfiguration);
+
+ /// The file the driver's delete of that key would unlink, every link on the way to it followed: see .
+ public string PhysicalObject(JsonElement nonSecretConfiguration, string objectKey) => LocalRootPath.ObjectPath(nonSecretConfiguration, ObjectsDirectory, objectKey);
+
private static JsonElement ParseSchema(string json)
{
using var document = JsonDocument.Parse(json);
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionReaper.cs b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionReaper.cs
index 68aa49081..dc9f686fd 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionReaper.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Artifacts/Retention/ArtifactRetentionReaper.cs
@@ -1,5 +1,7 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
using CodeSpace.Core.Services.Workflows.Artifacts.Runtime;
using CodeSpace.Messages.Artifacts;
using CodeSpace.Messages.Constants;
@@ -60,11 +62,14 @@ public sealed class ArtifactRetentionReaper : IArtifactRetentionReaper
private readonly IArtifactBlobPurge? _purge;
private readonly IArtifactCasPurgeCoordinator _routedPurge;
+ /// The provider modules a routed placement's object is derived through when its key collides with another's.
+ private readonly IStorageProviderModuleCatalog _modules;
+
private readonly ILogger _logger;
private readonly ArtifactRetentionReaperOptions _options;
public ArtifactRetentionReaper(DbContextOptions dbOptions, IArtifactReferenceOracle oracle, IArtifactBlobBackend blobs,
- IArtifactCasPurgeCoordinator routedPurge, ILogger logger) : this(new ArtifactRetentionReaperServices(dbOptions, oracle, blobs, routedPurge, logger), Defaults) { }
+ IArtifactCasPurgeCoordinator routedPurge, IStorageProviderModuleCatalog modules, ILogger logger) : this(new ArtifactRetentionReaperServices(dbOptions, oracle, blobs, routedPurge, modules, logger), Defaults) { }
internal ArtifactRetentionReaper(ArtifactRetentionReaperServices services, ArtifactRetentionReaperOptions options)
{
@@ -75,6 +80,7 @@ internal ArtifactRetentionReaper(ArtifactRetentionReaperServices services, Artif
_oracle = services.Oracle;
_purge = services.Blobs as IArtifactBlobPurge;
_routedPurge = services.RoutedPurge;
+ _modules = services.Modules;
_logger = services.Logger;
_options = options;
}
@@ -447,14 +453,24 @@ private static ArtifactRetentionDecision ClaimRejection(ArtifactCasProblem probl
/// The routed arm of the same sharing question the local arm asks below, and it has to be asked for the same
/// reason: ArtifactStore.ObjectKeyFor builds workflow-artifacts/{aa}/{bb}/{sha256} with NO team
/// segment, so two objects holding identical content land on the same key. Whether that key is the same PHYSICAL
- /// object then depends entirely on whether the two profile revisions name the same namespace — which is what
- /// storage_profile_revision.namespace_fingerprint identifies, and the only thing that identifies it.
+ /// object then depends on where each profile revision's key actually leads.
+ ///
+ /// That object is re-derived HERE, from each revision's configuration through its own provider module
+ /// (), and never read from
+ /// storage_profile_revision.namespace_fingerprint. The stored column cannot answer: revisions are immutable,
+ /// and every one admitted before namespaces were canonical hashed the configuration exactly as typed, so a root
+ /// spelled R by one team and R/ by another stored two fingerprints for one directory — and this probe,
+ /// reading them, let one team's sweep delete the other team's live bytes. Nor can any namespace, for a filesystem:
+ /// a link beneath a root the collector owns outright (objects, or any directory below it) carries its key
+ /// into the victim's directory, so a local provider is asked for the file the key reaches, every link on the way
+ /// followed as the operating system follows it, at the moment of the sweep.
///
- /// Comparing the object key ALONE would be unsound in the safe direction but useless in practice: every team
- /// storing the same bytes shares a key, so the reaper would refuse to collect any deduplicated content even when
- /// the two teams are in different buckets. Comparing the fingerprint alone would be unsound in the other
- /// direction. Both together are the question "is this the same object", which is the one the local arm answers
- /// with a storage_url comparison because there the url already carries both.
+ /// A placement whose object cannot be derived — a provider this build does not register, a configuration
+ /// it can no longer read — is "unknown", and unknown collides with everything at its key. Comparing the object key
+ /// ALONE would be unsound in the safe direction but useless in practice: every team storing the same bytes shares
+ /// a key, so the reaper would refuse to collect any deduplicated content even when the two teams are in different
+ /// buckets. The key and the namespace together are the question "is this the same object", which is the one the
+ /// local arm answers with a storage_url comparison because there the url already carries both.
///
/// Deliberately NOT team-scoped, and deliberately not filtered by . A
/// location whose bytes are already Purged cannot really be harmed, so including it only ever costs a kept
@@ -466,31 +482,46 @@ private static ArtifactRetentionDecision ClaimRejection(ArtifactCasProblem probl
/// commits. Closing it would need the placement path to hold a lock across its transfer and its location insert,
/// which this lane does not change.
///
- private static async Task RoutedPurgePathAsync(CodeSpaceDbContext db, Guid objectId, CancellationToken cancellationToken)
+ private async Task RoutedPurgePathAsync(CodeSpaceDbContext db, Guid objectId, CancellationToken cancellationToken)
{
- var mine = await db.ArtifactLocation.AsNoTracking()
- .Where(location => location.ArtifactObjectId == objectId)
- .Join(db.StorageProfileRevision.AsNoTracking(), location => location.StorageProfileRevisionId, revision => revision.Id,
- (location, revision) => new PlacedAt(location.ObjectKey, revision.NamespaceFingerprint))
- .Distinct()
- .ToListAsync(cancellationToken).ConfigureAwait(false);
+ var mine = await StoredPlacementsAsync(db, db.ArtifactLocation.AsNoTracking().Where(location => location.ArtifactObjectId == objectId), cancellationToken).ConfigureAwait(false);
if (mine.Count == 0) return ArtifactPurgePath.Routed;
var keys = mine.Select(placed => placed.ObjectKey).Distinct().ToList();
+ var collisions = await StoredPlacementsAsync(db, db.ArtifactLocation.AsNoTracking().Where(location => location.ArtifactObjectId != objectId && keys.Contains(location.ObjectKey)), cancellationToken).ConfigureAwait(false);
+
+ if (collisions.Count == 0) return ArtifactPurgePath.Routed;
+
+ return SharesAnObject(Identify(mine), Identify(collisions)) ? ArtifactPurgePath.RoutedObjectShared : ArtifactPurgePath.Routed;
+ }
- var collisions = await db.ArtifactLocation.AsNoTracking()
- .Where(location => location.ArtifactObjectId != objectId && keys.Contains(location.ObjectKey))
+ private static async Task> StoredPlacementsAsync(CodeSpaceDbContext db, IQueryable locations, CancellationToken cancellationToken) =>
+ await locations
.Join(db.StorageProfileRevision.AsNoTracking(), location => location.StorageProfileRevisionId, revision => revision.Id,
- (location, revision) => new PlacedAt(location.ObjectKey, revision.NamespaceFingerprint))
+ (location, revision) => new StoredPlacement(location.ObjectKey, revision.ProviderTypeKey, revision.NonSecretConfigJson))
.Distinct()
.ToListAsync(cancellationToken).ConfigureAwait(false);
- return collisions.Any(mine.Contains) ? ArtifactPurgePath.RoutedObjectShared : ArtifactPurgePath.Routed;
- }
+ /// Each placement's key beside the object it reaches today, derived through the provider that owns it.
+ private List Identify(List placements) =>
+ placements.Select(placed => new PlacedAt(placed.ObjectKey, StorageProfileRules.TryObjectIdentity(_modules, placed.ProviderTypeKey, placed.NonSecretConfigJson, placed.ObjectKey))).ToList();
+
+ private static bool SharesAnObject(List mine, List collisions) => collisions.Any(other => mine.Any(own => own.IsOnePhysicalObjectWith(other)));
- /// One physical destination: the key, plus the namespace identity that says WHICH store that key is inside.
- private sealed record PlacedAt(string ObjectKey, string NamespaceFingerprint);
+ /// A location's key and its revision's configuration, as stored: the raw material of an object identity, not the identity itself.
+ private sealed record StoredPlacement(string ObjectKey, string ProviderTypeKey, string NonSecretConfigJson);
+
+ ///
+ /// One physical destination: the key, plus the identity of the object that key reaches. A null identity is one that
+ /// could not be derived, and it is treated as every object at that key at once: the only cost of that reading is a
+ /// kept byte.
+ ///
+ private sealed record PlacedAt(string ObjectKey, string? Object)
+ {
+ public bool IsOnePhysicalObjectWith(PlacedAt other) =>
+ ObjectKey == other.ObjectKey && (Object == null || other.Object == null || Object == other.Object);
+ }
///
/// Applies the outcome under a proven claim. The COLLECT arm additionally re-asks the reference question inside this
@@ -729,6 +760,6 @@ public void Record(SweepSettlement settlement)
}
internal sealed record ArtifactRetentionReaperServices(DbContextOptions DbOptions, IArtifactReferenceOracle Oracle,
- IArtifactBlobBackend Blobs, IArtifactCasPurgeCoordinator RoutedPurge, ILogger Logger);
+ IArtifactBlobBackend Blobs, IArtifactCasPurgeCoordinator RoutedPurge, IStorageProviderModuleCatalog Modules, ILogger Logger);
internal sealed record ArtifactRetentionReaperOptions(int BatchSize, int ClaimSize, int MaxAttempts, TimeSpan LeaseDuration, TimeSpan OperationTimeout, TimeSpan RetryDelay);
diff --git a/backend/tests/CodeSpace.E2ETests/Storage/StorageNamespaceSharingE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Storage/StorageNamespaceSharingE2ETests.cs
new file mode 100644
index 000000000..77391648d
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Storage/StorageNamespaceSharingE2ETests.cs
@@ -0,0 +1,443 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Workflows.Artifacts;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+using CodeSpace.Core.Services.Workflows.Artifacts.Retention;
+using CodeSpace.E2ETests.Infrastructure;
+using CodeSpace.Messages.Artifacts;
+using CodeSpace.Messages.Constants;
+using CodeSpace.Messages.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace CodeSpace.E2ETests.Storage;
+
+///
+/// Two teams, one physical directory, two spellings of it — and the retention sweep of one team must not delete the
+/// other team's live bytes.
+///
+/// The shape an audit proved destructive: a victim team's admin points workflow-artifact/v1 at a local root
+/// R and stores an artifact; a member of that team who owns a Personal team points their own destination at
+/// R/ (or R/., or any other spelling of R) and stores the same bytes, unreferenced. Object keys are
+/// content-addressed with no team segment, so both teams' locations name ONE file. The sharing guard compared a hash of
+/// the configuration as typed, saw two namespaces, and collected the second team's object — deleting the file the
+/// victim's download then answered 502 for.
+///
+/// A spelling need not name the victim's root at all. The driver opens <root>/objects/<key>
+/// and the operating system follows a link anywhere along it, so a collector root that is a directory of its own, with
+/// objects (or a directory below it) linked into the victim's, reaches the same file — and so does a link whose
+/// target climbs out through another link (L -> a/..), which the operating system resolves before it
+/// climbs.
+///
+/// Fidelity: high. Every destination is created over real HTTP through admission, where the namespace
+/// fingerprint is derived; bytes land through the production routed write path into the real local-rwx driver on real
+/// directories; the real reaper sweeps the real Postgres database; and the victim reads back over real HTTP. The only
+/// non-production steps are the two that have no HTTP surface or would take a week: bytes are put through the in-process
+/// and seams (no endpoint uploads an artifact; runs
+/// do, through these same classes), and the declaration is aged in SQL past its age floor and quarantine window.
+///
+[Trait("Category", "E2E")]
+[Trait("Surface", "Http")]
+public sealed class StorageNamespaceSharingE2ETests : IClassFixture, IDisposable
+{
+ /// Two sweeps decide any declaration here; the slack absorbs a sweep another class's rows crowd, and a loop that stops progressing fails instead of hanging.
+ private const int MaxSweeps = 6;
+
+ private readonly TaskLaunchApiFactory _factory;
+ private readonly List _paths = [];
+
+ public StorageNamespaceSharingE2ETests(TaskLaunchApiFactory factory) { _factory = factory; }
+
+ [Theory]
+ [InlineData("as-written")]
+ [InlineData("trailing-slash")]
+ [InlineData("dot-segment")]
+ [InlineData("doubled-slash")]
+ [InlineData("relative")]
+ [InlineData("symlink")]
+ [InlineData("dotdot-through-link")]
+ [InlineData("objects-link")]
+ [InlineData("nested-link")]
+ public async Task A_second_team_reaching_the_victims_root_through_any_spelling_cannot_collect_the_victims_bytes(string spelling)
+ {
+ if (spelling.Contains("link") && OperatingSystem.IsWindows()) return;
+
+ var world = await SeedTwoTeamsAsync();
+ var root = Root();
+ var payload = Payload($"victim deliverable {spelling}");
+
+ await CreateDestinationAsync(world.Victim, root);
+ var victimArtifactId = await PutRoutedAsync(world.Victim.TeamId, payload);
+ var spelled = Spell(root, spelling);
+ await CreateDestinationAsync(world.Collector, spelled);
+ var collectorArtifactId = await PutDeclaredAsync(world.Collector.TeamId, payload);
+ var file = ObjectPath(root, payload);
+
+ File.Exists(file).ShouldBeTrue($"the precondition: both teams' writes landed on ONE file at {file}");
+ File.Exists(ObjectPath(spelled, payload)).ShouldBeTrue($"the precondition: the operating system opens that file through '{spelled}'");
+
+ await SweepUntilSettledAsync(collectorArtifactId);
+
+ var declaration = (await DeclarationAsync(collectorArtifactId)).ShouldNotBeNull(
+ $"the collector's artifact was COLLECTED through '{spelled}', which reaches '{root}' — so the victim's bytes went with it");
+ declaration.State.ShouldBe(ArtifactRetentionState.Indeterminate);
+ declaration.LastErrorCode.ShouldBe("artifact-routed-object-shared", "the kept row must say WHY, not merely survive");
+ File.Exists(file).ShouldBeTrue($"the victim's object is gone from disk; `ls {Path.GetDirectoryName(file)}` shows what the sweep left");
+
+ var download = await DownloadAsync(world.Victim, victimArtifactId);
+ download.StatusCode.ShouldBe(HttpStatusCode.OK, $"the victim's own artifact must still download after the other team's sweep: {await download.Content.ReadAsStringAsync()}");
+ (await download.Content.ReadAsByteArrayAsync()).ShouldBe(payload);
+ }
+
+ [Fact]
+ public async Task A_victim_destination_admitted_before_namespaces_were_canonical_is_still_protected()
+ {
+ // Every destination already in a database was admitted when the fingerprint hashed the configuration as typed,
+ // and revisions are immutable, so those stored values never change. A victim whose root was typed 'R/' carries
+ // that text's hash; a collector admitted now at 'R' carries the directory's. Only a sweep that re-derives both
+ // namespaces from their configurations sees that they are one directory.
+ var world = await SeedTwoTeamsAsync();
+ var root = Root();
+ var payload = Payload("victim deliverable from before canonical admission");
+
+ await SeedDestinationAdmittedBeforeCanonicalAsync(world.Victim, root + "/");
+ var victimArtifactId = await PutRoutedAsync(world.Victim.TeamId, payload);
+ await CreateDestinationAsync(world.Collector, root);
+ var collectorArtifactId = await PutDeclaredAsync(world.Collector.TeamId, payload);
+
+ (await StoredFingerprintsAsync(world)).Distinct().Count().ShouldBe(2, "the precondition: the stored fingerprints disagree, so they cannot be what keeps these bytes");
+
+ await SweepUntilSettledAsync(collectorArtifactId);
+
+ var declaration = (await DeclarationAsync(collectorArtifactId)).ShouldNotBeNull("the sweep trusted the victim's stored fingerprint and collected the shared object");
+ declaration.LastErrorCode.ShouldBe("artifact-routed-object-shared");
+
+ var download = await DownloadAsync(world.Victim, victimArtifactId);
+ download.StatusCode.ShouldBe(HttpStatusCode.OK, await download.Content.ReadAsStringAsync());
+ (await download.Content.ReadAsByteArrayAsync()).ShouldBe(payload);
+ }
+
+ [Fact]
+ public async Task A_second_team_in_a_root_of_its_own_still_has_its_unreferenced_bytes_collected()
+ {
+ // The positive control: content addressing gives both teams the same key, so if a different directory also
+ // counted as shared, deduplicated content would never be collected and every theory case above would pass
+ // for the wrong reason.
+ var world = await SeedTwoTeamsAsync();
+ var victimRoot = Root();
+ var collectorRoot = Root();
+ var payload = Payload("one payload in two separate directories");
+
+ await CreateDestinationAsync(world.Victim, victimRoot);
+ var victimArtifactId = await PutRoutedAsync(world.Victim.TeamId, payload);
+ await CreateDestinationAsync(world.Collector, collectorRoot + "/");
+ var collectorArtifactId = await PutDeclaredAsync(world.Collector.TeamId, payload);
+
+ await SweepUntilSettledAsync(collectorArtifactId);
+
+ (await DeclarationAsync(collectorArtifactId)).ShouldBeNull("the collector's directory is its own, so nothing else reaches its bytes and they are collected");
+ File.Exists(ObjectPath(collectorRoot, payload)).ShouldBeFalse("collection removes the bytes the collected row named");
+ File.Exists(ObjectPath(victimRoot, payload)).ShouldBeTrue("a different directory holding the same key is a different object");
+
+ var download = await DownloadAsync(world.Victim, victimArtifactId);
+ download.StatusCode.ShouldBe(HttpStatusCode.OK, await download.Content.ReadAsStringAsync());
+ (await download.Content.ReadAsByteArrayAsync()).ShouldBe(payload);
+ }
+
+ // ─── World + helpers ─────────────────────────────────────────────────────
+
+ private sealed record Member(Guid UserId, Guid TeamId);
+
+ private sealed record World(Member Victim, Member Collector);
+
+ ///
+ /// The victim: Owner of a workspace team. The collector: a Viewer of that team — so it can read the team's bytes
+ /// but has no storage rights there — and Owner of its own Personal team, where it has every storage right.
+ ///
+ private async Task SeedTwoTeamsAsync()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var suffix = Guid.NewGuid().ToString("N")[..8];
+ var victim = new Member(Guid.NewGuid(), Guid.NewGuid());
+ var collector = new Member(Guid.NewGuid(), Guid.NewGuid());
+
+ db.User.AddRange(
+ new User { Id = victim.UserId, SecurityStamp = TestToken.SeedStamp, Email = $"namespace-victim-{suffix}@test.local", Name = "Victim", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId },
+ new User { Id = collector.UserId, SecurityStamp = TestToken.SeedStamp, Email = $"namespace-collector-{suffix}@test.local", Name = "Collector", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId });
+ db.Team.AddRange(
+ new Team { Id = victim.TeamId, Slug = $"namespace-victim-{suffix}", Name = "Victim", Kind = TeamKind.Workspace, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId },
+ new Team { Id = collector.TeamId, Slug = $"namespace-personal-{suffix}", Name = "Personal", Kind = TeamKind.Personal, PersonalForUserId = collector.UserId, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId });
+ db.TeamMembership.AddRange(
+ new TeamMembership { Id = Guid.NewGuid(), TeamId = victim.TeamId, UserId = victim.UserId, Role = TeamRole.Owner, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId },
+ new TeamMembership { Id = Guid.NewGuid(), TeamId = victim.TeamId, UserId = collector.UserId, Role = TeamRole.Viewer, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId },
+ new TeamMembership { Id = Guid.NewGuid(), TeamId = collector.TeamId, UserId = collector.UserId, Role = TeamRole.Owner, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId });
+ await db.SaveChangesAsync();
+
+ return new World(victim, collector);
+ }
+
+ /// The Settings "add destination" call: admits the profile, activates it and routes workflow-artifact/v1 at it in one step.
+ private async Task CreateDestinationAsync(Member member, string rootPath)
+ {
+ var response = await SendAsync(member, HttpMethod.Post, "/api/storage/destinations", new
+ {
+ name = $"namespace-{Guid.NewGuid():N}"[..24],
+ providerTypeKey = "local-rwx/v1",
+ nonSecretConfig = new { rootPath },
+ dataClassTypeKeys = new[] { "workflow-artifact/v1" },
+ });
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK, $"creating a destination at '{rootPath}': {await response.Content.ReadAsStringAsync()}");
+ }
+
+ ///
+ /// A destination exactly as admission recorded it before namespaces were canonical: its fingerprint is the hash of
+ /// the configuration as typed. Seeded rather than created, because no endpoint can produce that row any more.
+ ///
+ private async Task SeedDestinationAdmittedBeforeCanonicalAsync(Member member, string rootPath)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var config = JsonSerializer.Serialize(new { rootPath });
+ var now = DateTimeOffset.UtcNow;
+ var profileId = Guid.NewGuid();
+ var routeId = Guid.NewGuid();
+ using var typed = JsonDocument.Parse(config);
+
+ db.StorageProfile.Add(new StorageProfile
+ {
+ Id = profileId, TeamId = member.TeamId, StableName = $"pre-canonical-{profileId:N}", CurrentRevision = 1,
+ State = StorageProfileState.Active, CreatedDate = now, CreatedBy = member.UserId, LastModifiedDate = now, LastModifiedBy = member.UserId,
+ Revisions =
+ {
+ new StorageProfileRevision
+ {
+ Id = Guid.NewGuid(), TeamId = member.TeamId, StorageProfileId = profileId, Revision = 1, ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey,
+ NonSecretConfigJson = config, CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, typed.RootElement),
+ CreatedDate = now, CreatedBy = member.UserId,
+ },
+ },
+ });
+ db.StorageRoute.Add(new StorageRoute
+ {
+ Id = routeId, TeamId = member.TeamId, DataClassTypeKey = "workflow-artifact/v1", CurrentRevision = 1,
+ State = StorageRouteState.Draft, CreatedDate = now, CreatedBy = member.UserId, LastModifiedDate = now, LastModifiedBy = member.UserId,
+ Revisions =
+ {
+ new StorageRouteRevision
+ {
+ Id = Guid.NewGuid(), TeamId = member.TeamId, StorageRouteId = routeId, Revision = 1, StorageProfileId = profileId,
+ ProfileRevisionMode = StorageProfileRevisionMode.CurrentAtWrite, PinnedProfileRevision = null, CreatedDate = now, CreatedBy = member.UserId,
+ },
+ },
+ });
+ await db.SaveChangesAsync();
+ await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE storage_route SET state = 'Active' WHERE id = {routeId}");
+ }
+
+ private async Task> StoredFingerprintsAsync(World world)
+ {
+ using var scope = _factory.Services.CreateScope();
+
+ return await scope.ServiceProvider.GetRequiredService().StorageProfileRevision.AsNoTracking()
+ .Where(revision => revision.TeamId == world.Victim.TeamId || revision.TeamId == world.Collector.TeamId)
+ .Select(revision => revision.NamespaceFingerprint).ToListAsync();
+ }
+
+ private async Task PutRoutedAsync(Guid teamId, byte[] payload)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var artifactId = await scope.ServiceProvider.GetRequiredService().PutAsync(teamId, payload, "application/octet-stream", CancellationToken.None);
+
+ await EnsureRoutedAsync(artifactId);
+ return artifactId;
+ }
+
+ /// The declaring write ArtifactManifestStore makes, before its manifest row exists — the unreferenced shape retention collects.
+ private async Task PutDeclaredAsync(Guid teamId, byte[] payload)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var write = await scope.ServiceProvider.GetRequiredService().PutDeclaredAsync(
+ new ArtifactRetentionWriteRequest(teamId, payload, "application/octet-stream", ArtifactRetentionClass.ArtifactManifestContent, "artifact_manifest", Guid.NewGuid()), CancellationToken.None);
+
+ write.Declared.ShouldBeTrue("a first routed write must mint the declaration this test sweeps");
+ await EnsureRoutedAsync(write.ArtifactId);
+ return write.ArtifactId;
+ }
+
+ private async Task EnsureRoutedAsync(Guid artifactId)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var row = await scope.ServiceProvider.GetRequiredService().WorkflowArtifact.AsNoTracking().SingleAsync(value => value.Id == artifactId);
+
+ row.CasArtifactObjectId.ShouldNotBeNull("a write that stayed inline or fell back to local disk proves nothing about a routed namespace");
+ }
+
+ ///
+ /// Ages the declaration past its class's age floor, then sweeps until it settles — collected, or kept terminally —
+ /// re-ageing an opened quarantine between sweeps.
+ ///
+ private async Task SweepUntilSettledAsync(Guid artifactId)
+ {
+ await AgeAsync(artifactId);
+
+ for (var sweep = 0; sweep < MaxSweeps; sweep++)
+ {
+ using (var scope = _factory.Services.CreateScope())
+ await scope.ServiceProvider.GetRequiredService().SweepAsync(CancellationToken.None);
+
+ var declaration = await DeclarationAsync(artifactId);
+ if (declaration == null || declaration.State == ArtifactRetentionState.Indeterminate) return;
+
+ await AgeQuarantineAsync(artifactId);
+ }
+
+ var stuck = await DeclarationAsync(artifactId);
+ throw new ShouldAssertException($"declaration {artifactId} did not settle within {MaxSweeps} sweeps (state {stuck?.State}, last error '{stuck?.LastErrorCode}'); "
+ + $"inspect it with `SELECT * FROM workflow_artifact_retention WHERE artifact_id = '{artifactId}'`");
+ }
+
+ private async Task AgeAsync(Guid artifactId)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // workflow_artifact rejects UPDATE (migration 0016). The trigger is disabled inside ONE transaction, which is
+ // what scopes it: DISABLE TRIGGER is a catalog change every session sees until it is re-enabled.
+ await using (var transaction = await db.Database.BeginTransactionAsync())
+ {
+ await db.Database.ExecuteSqlRawAsync("ALTER TABLE workflow_artifact DISABLE TRIGGER workflow_artifact_enforce_immutability");
+ await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE workflow_artifact SET created_at = clock_timestamp() - interval '30 days' WHERE id = {artifactId}");
+ await db.Database.ExecuteSqlRawAsync("ALTER TABLE workflow_artifact ENABLE TRIGGER workflow_artifact_enforce_immutability");
+ await transaction.CommitAsync();
+ }
+
+ await db.Database.ExecuteSqlInterpolatedAsync($"""
+ UPDATE workflow_artifact_retention
+ SET declared_at = clock_timestamp() - interval '30 days', next_sweep_at = clock_timestamp() - interval '30 days', last_modified_at = clock_timestamp()
+ WHERE artifact_id = {artifactId}
+ """);
+ }
+
+ private async Task AgeQuarantineAsync(Guid artifactId)
+ {
+ using var scope = _factory.Services.CreateScope();
+ await scope.ServiceProvider.GetRequiredService().Database.ExecuteSqlInterpolatedAsync($"""
+ UPDATE workflow_artifact_retention
+ SET quarantined_at = clock_timestamp() - interval '2 days', next_sweep_at = clock_timestamp() - interval '2 days', last_modified_at = clock_timestamp()
+ WHERE artifact_id = {artifactId} AND state = 'Quarantined'
+ """);
+ }
+
+ private async Task DeclarationAsync(Guid artifactId)
+ {
+ using var scope = _factory.Services.CreateScope();
+
+ return await scope.ServiceProvider.GetRequiredService().WorkflowArtifactRetention.AsNoTracking().SingleOrDefaultAsync(row => row.ArtifactId == artifactId);
+ }
+
+ private async Task DownloadAsync(Member member, Guid artifactId) => await SendAsync(member, HttpMethod.Get, $"/api/artifacts/{artifactId}");
+
+ private async Task SendAsync(Member member, HttpMethod method, string path, object? body = null)
+ {
+ var request = new HttpRequestMessage(method, path);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestToken.Mint(member.UserId, TestToken.SeedStamp));
+ request.Headers.Add("X-Team-Id", member.TeamId.ToString());
+ if (body != null) request.Content = JsonContent.Create(body);
+
+ return await _factory.CreateClient().SendAsync(request);
+ }
+
+ /// A real directory of this test's own, GUID-named so repeated and concurrent runs never meet.
+ private string Root()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "codespace-namespace-e2e", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ _paths.Add(root);
+ return root;
+ }
+
+ /// The spelling under test; the local-rwx driver opens every one of them as itself.
+ private string Spell(string root, string spelling) => spelling switch
+ {
+ "as-written" => root,
+ "trailing-slash" => root + "/",
+ "dot-segment" => root + "/.",
+ "doubled-slash" => root + "//",
+ "relative" => Path.GetRelativePath(Environment.CurrentDirectory, root),
+ "symlink" => LinkTo(root),
+ "dotdot-through-link" => DotDotThroughLink(root),
+ "objects-link" => LinkBeneath(root, "objects"),
+ "nested-link" => LinkBeneath(root, Path.Combine("objects", "workflow-artifacts")),
+ _ => throw new ArgumentOutOfRangeException(nameof(spelling), spelling, null),
+ };
+
+ ///
+ /// holder/L -> a/.. with holder/a -> root/objects: the operating system follows a before
+ /// it climbs, so holder/L is root — while collapsing the target as text would call it holder.
+ ///
+ private string DotDotThroughLink(string root)
+ {
+ var holder = Root();
+ Directory.CreateSymbolicLink(Path.Combine(holder, "a"), Path.Combine(root, "objects"));
+ Directory.CreateSymbolicLink(Path.Combine(holder, "L"), Path.Combine("a", ".."));
+ return Path.Combine(holder, "L");
+ }
+
+ /// A root of the collector's own whose is a link into : the root itself names nothing shared.
+ private string LinkBeneath(string root, string beneath)
+ {
+ var collectorRoot = Root();
+ var link = Path.Combine(collectorRoot, beneath);
+ Directory.CreateDirectory(Path.GetDirectoryName(link)!);
+ Directory.CreateSymbolicLink(link, Path.Combine(root, beneath));
+ return collectorRoot;
+ }
+
+ private string LinkTo(string root)
+ {
+ var link = Path.Combine(Path.GetTempPath(), "codespace-namespace-e2e", $"link-{Guid.NewGuid():N}");
+ Directory.CreateSymbolicLink(link, root);
+ _paths.Add(link);
+ return link;
+ }
+
+ /// Where the local-rwx driver keeps these bytes: <root>/objects/workflow-artifacts/{aa}/{bb}/{sha256}, the key with no team in it.
+ private static string ObjectPath(string root, byte[] payload)
+ {
+ var sha = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(payload));
+ return Path.Combine(root, "objects", "workflow-artifacts", sha[..2], sha.Substring(2, 2), sha);
+ }
+
+ /// 32 KiB, past the inline threshold, so the bytes must leave the row for the destination.
+ private static byte[] Payload(string content)
+ {
+ var head = System.Text.Encoding.UTF8.GetBytes($"{content} {Guid.NewGuid():N}\n");
+ var bytes = new byte[32 * 1024];
+ head.CopyTo(bytes, 0);
+ for (var i = head.Length; i < bytes.Length; i++) bytes[i] = (byte)('a' + (i % 26));
+
+ return bytes;
+ }
+
+ public void Dispose()
+ {
+ foreach (var path in Enumerable.Reverse(_paths))
+ {
+ try
+ {
+ if (new FileInfo(path).LinkTarget != null) File.Delete(path);
+ else if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
index 41fe1898f..6802a55de 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs
@@ -11,6 +11,7 @@
using CodeSpace.Core.Services.Workflows.Artifacts.Runtime;
using CodeSpace.Core.Settings;
using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.Messages.Commands.Storage;
using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Dtos.Agents;
using CodeSpace.Messages.Enums;
@@ -211,6 +212,40 @@ public async Task First_resolution_creates_one_idempotent_settings_visible_local
config.RootElement.GetProperty("rootPath").GetString().ShouldBe(Path.GetFullPath(rootPath));
}
+ [Fact]
+ public async Task The_bootstrap_destination_names_its_namespace_exactly_as_settings_admission_names_that_directory()
+ {
+ // The bootstrap writes its revision without going through admission, so it must derive the namespace the way
+ // admission does. Reached through a link here, the deployment root's TEXT differs from the directory an operator
+ // types into Settings, while the directory itself is one: a fingerprint of the text would name two namespaces.
+ if (OperatingSystem.IsWindows()) return;
+
+ var world = await SeedWorldAsync();
+ var real = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "codespace-agent-log-namespace", Guid.NewGuid().ToString("N"))).FullName;
+ var link = real + "-link";
+ Directory.CreateSymbolicLink(link, real);
+
+ try
+ {
+ using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = link, ArtifactLocalRwxShared = true });
+ using var scope = _fixture.BeginScope();
+
+ var bootstrap = (await scope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType();
+ using var typed = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath = real + "/" }));
+ var admitted = await scope.Resolve().CreateAsync(world.TeamId, world.ActorId, new CreateStorageProfileCommand { StableName = $"admitted-{Guid.NewGuid():N}", ProviderTypeKey = "local-rwx/v1", NonSecretConfig = typed.RootElement }, CancellationToken.None);
+
+ var bootstrapFingerprint = await scope.Resolve().StorageProfileRevision.AsNoTracking()
+ .Where(value => value.StorageProfileId == bootstrap.StorageProfileId).Select(value => value.NamespaceFingerprint).SingleAsync();
+
+ bootstrapFingerprint.ShouldBe(admitted.Revisions.ShouldHaveSingleItem().NamespaceFingerprint, $"'{link}' and '{real}/' are one directory, so the bootstrap and admission must name one namespace");
+ }
+ finally
+ {
+ File.Delete(link);
+ Directory.Delete(real, recursive: true);
+ }
+ }
+
[Fact]
public async Task Reserved_profile_collision_is_not_hijacked_and_missing_route_stays_explicit()
{
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.Namespaces.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.Namespaces.cs
new file mode 100644
index 000000000..1dac29ea6
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.Namespaces.cs
@@ -0,0 +1,359 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using Autofac;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+using CodeSpace.Messages.Artifacts;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+
+namespace CodeSpace.IntegrationTests.Workflows;
+
+///
+/// The routed sharing guard against REAL namespaces: two teams, real directories, a real file at the shared key, and
+/// the real local-rwx driver the purge would delete it through.
+///
+/// Every revision here carries the fingerprint pre-canonical admission stored — the hash of the configuration
+/// exactly as typed — because that is what every revision already in a database carries, and revisions are
+/// immutable. So a guard that read the stored column would see two spellings of one root as two namespaces, and these
+/// tests fail; only a guard that re-derives the physical namespace from the configuration at sweep time passes.
+///
+/// "Spelling" includes links the collector plants beneath a root it owns outright: the driver opens
+/// <root>/objects/<key> and the operating system follows a link anywhere along that path, so only a
+/// guard that resolves the object a key reaches — not merely the root a configuration names — sees those as shared.
+///
+public sealed partial class ArtifactRetentionReaperFlowTests : IDisposable
+{
+ private readonly List _namespaceRoots = [];
+
+ [Theory]
+ [InlineData("as-written")]
+ [InlineData("trailing-slash")]
+ [InlineData("dot-segment")]
+ [InlineData("doubled-slash")]
+ [InlineData("relative")]
+ [InlineData("symlink")]
+ [InlineData("dotdot-through-link")]
+ [InlineData("objects-link")]
+ [InlineData("nested-link")]
+ public async Task A_routed_object_another_team_reaches_through_another_spelling_of_one_root_is_kept(string spelling)
+ {
+ if (spelling.Contains("link") && OperatingSystem.IsWindows()) return;
+
+ var victim = await SeedWorldAsync();
+ var collector = await SeedWorldAsync();
+ var root = NamespaceRoot();
+ var bytes = RoutedBytes("one payload two teams both stored");
+ var file = PlaceObject(root, bytes);
+ var spelled = Spell(root, spelling);
+
+ File.Exists(Path.Combine([spelled, "objects", .. ObjectKeyOf(bytes).Split('/')])).ShouldBeTrue($"the precondition: the operating system opens {file} through '{spelled}'");
+
+ var victimPlacement = await SeedPlacementAsync(victim, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(root));
+ var collectorPlacement = await SeedPlacementAsync(collector, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(spelled));
+ await DeclarePlacementAsync(collector, collectorPlacement.ArtifactId);
+
+ (await StoredFingerprintAsync(victimPlacement) == await StoredFingerprintAsync(collectorPlacement)).ShouldBe(spelling == "as-written",
+ "the precondition that makes this a regression test: the stored fingerprints disagree for every spelling but the literal one");
+
+ await SweepToSettlementAsync(collectorPlacement.ArtifactId);
+
+ var declaration = (await DeclarationAsync(collectorPlacement.ArtifactId)).ShouldNotBeNull(
+ $"the artifact was COLLECTED: '{spelled}' reaches the file '{root}' holds, so its delete removed the victim's bytes");
+ declaration.State.ShouldBe(ArtifactRetentionState.Indeterminate);
+ declaration.LastErrorCode.ShouldBe("artifact-routed-object-shared", "the kept row must say WHY, not merely survive");
+ File.Exists(file).ShouldBeTrue($"the victim's object at {file} is the collector's object too; check `ls {Path.GetDirectoryName(file)}`");
+ (await File.ReadAllBytesAsync(file)).ShouldBe(bytes);
+ (await LocationStateAsync(victimPlacement.LocationId)).ShouldBe(ArtifactLocationState.Available);
+ }
+
+ [Fact]
+ public async Task A_routed_object_whose_key_another_team_holds_in_a_physically_distinct_root_is_still_collected()
+ {
+ // The positive control for the theory above: content addressing gives every team storing these bytes the same
+ // key, so if distinct directories also counted as shared, deduplicated content would never be collected.
+ var victim = await SeedWorldAsync();
+ var collector = await SeedWorldAsync();
+ var victimRoot = NamespaceRoot();
+ var collectorRoot = NamespaceRoot();
+ var bytes = RoutedBytes("one payload in two separate directories");
+ var victimFile = PlaceObject(victimRoot, bytes);
+ var collectorFile = PlaceObject(collectorRoot, bytes);
+
+ var victimPlacement = await SeedPlacementAsync(victim, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(victimRoot));
+ var collectorPlacement = await SeedPlacementAsync(collector, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(collectorRoot + "/"));
+ await DeclarePlacementAsync(collector, collectorPlacement.ArtifactId);
+
+ await SweepToSettlementAsync(collectorPlacement.ArtifactId);
+
+ (await ArtifactExistsAsync(collectorPlacement.ArtifactId)).ShouldBeFalse("the collector's directory is its own, so nothing else reaches these bytes");
+ File.Exists(collectorFile).ShouldBeFalse("collection removes the bytes the collected row named");
+ File.Exists(victimFile).ShouldBeTrue("a different directory holding the same key is a different object");
+ (await LocationStateAsync(victimPlacement.LocationId)).ShouldBe(ArtifactLocationState.Available);
+ }
+
+ [Fact]
+ public async Task A_routed_object_whose_key_is_a_link_to_another_teams_file_is_collected_and_only_the_link_goes()
+ {
+ // The other edge of following links: a delete unlinks the NAME it is given, so a key that is itself a link
+ // removes the link and never the file it points at. Counting it as shared would only stall the sweep; proving
+ // the unlink here is what makes leaving the last component unresolved safe.
+ if (OperatingSystem.IsWindows()) return;
+
+ var victim = await SeedWorldAsync();
+ var collector = await SeedWorldAsync();
+ var victimRoot = NamespaceRoot();
+ var collectorRoot = NamespaceRoot();
+ var bytes = RoutedBytes("one payload one team links to");
+ var victimFile = PlaceObject(victimRoot, bytes);
+ var link = Path.Combine([collectorRoot, "objects", .. ObjectKeyOf(bytes).Split('/')]);
+ Directory.CreateDirectory(Path.GetDirectoryName(link)!);
+ File.CreateSymbolicLink(link, victimFile);
+
+ var victimPlacement = await SeedPlacementAsync(victim, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(victimRoot));
+ var collectorPlacement = await SeedPlacementAsync(collector, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(collectorRoot));
+ await DeclarePlacementAsync(collector, collectorPlacement.ArtifactId);
+
+ await SweepToSettlementAsync(collectorPlacement.ArtifactId);
+
+ (await ArtifactExistsAsync(collectorPlacement.ArtifactId)).ShouldBeFalse("the collector's key is a name of its own, so its object is collected");
+ new FileInfo(link).LinkTarget.ShouldBeNull("collection removes the link the collected row named");
+ File.Exists(victimFile).ShouldBeTrue($"the unlink must not reach through to {victimFile}");
+ (await File.ReadAllBytesAsync(victimFile)).ShouldBe(bytes);
+ (await LocationStateAsync(victimPlacement.LocationId)).ShouldBe(ArtifactLocationState.Available);
+ }
+
+ [Theory]
+ [InlineData("retired-provider/v1", """{"rootPath":""}""")]
+ [InlineData("local-rwx/v1", """{"unexpected":""}""")]
+ [InlineData("local-rwx/v1", """{"rootPath":42}""")]
+ public async Task A_key_collision_with_a_revision_whose_namespace_cannot_be_derived_is_kept(string providerTypeKey, string neighbourConfig)
+ {
+ // A provider this build no longer registers, or a configuration it can no longer read, names SOME namespace —
+ // this build just cannot say which. Reading that as "a different one" would delete on a guess.
+ var neighbour = await SeedWorldAsync();
+ var collector = await SeedWorldAsync();
+ var root = NamespaceRoot();
+ var bytes = RoutedBytes($"one payload beside an unreadable revision {providerTypeKey} {neighbourConfig}");
+ var file = PlaceObject(root, bytes);
+
+ await SeedPlacementAsync(neighbour, bytes, providerTypeKey, neighbourConfig.Replace("", JsonEncodedText.Encode(root).ToString()));
+ var collectorPlacement = await SeedPlacementAsync(collector, bytes, LocalRwxArtifactStorageDriverFactory.TypeKey, RootConfig(root));
+ await DeclarePlacementAsync(collector, collectorPlacement.ArtifactId);
+
+ await SweepToSettlementAsync(collectorPlacement.ArtifactId);
+
+ var declaration = (await DeclarationAsync(collectorPlacement.ArtifactId)).ShouldNotBeNull("an unknown namespace was read as a different one and the object was collected");
+ declaration.State.ShouldBe(ArtifactRetentionState.Indeterminate);
+ declaration.LastErrorCode.ShouldBe("artifact-routed-object-shared");
+ File.Exists(file).ShouldBeTrue();
+ }
+
+ // ─── Namespace helpers ───────────────────────────────────────────────────
+
+ private sealed record SeededPlacement(Guid ArtifactId, Guid LocationId, Guid RevisionId);
+
+ /// Ages the declaration past both waits and sweeps twice: the first sweep quarantines, the second decides.
+ private async Task SweepToSettlementAsync(Guid artifactId)
+ {
+ await AgeDeclarationAsync(artifactId, TimeSpan.FromDays(30));
+ await SweepAsync();
+ await AgeQuarantineAsync(artifactId, TimeSpan.FromDays(2));
+ await SweepAsync();
+ }
+
+ /// A real directory of this test's own, removed on dispose.
+ private string NamespaceRoot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "codespace-namespace-reaper", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ _namespaceRoots.Add(root);
+ return root;
+ }
+
+ /// The spelling under test, each of which the local-rwx driver opens as itself.
+ private string Spell(string root, string spelling) => spelling switch
+ {
+ "as-written" => root,
+ "trailing-slash" => root + "/",
+ "dot-segment" => root + "/.",
+ "doubled-slash" => root + "//",
+ "relative" => Path.GetRelativePath(Environment.CurrentDirectory, root),
+ "symlink" => LinkTo(root),
+ "dotdot-through-link" => DotDotThroughLink(root),
+ "objects-link" => LinkBeneath(root, "objects"),
+ "nested-link" => LinkBeneath(root, Path.Combine("objects", "workflow-artifacts")),
+ _ => throw new ArgumentOutOfRangeException(nameof(spelling), spelling, null),
+ };
+
+ ///
+ /// holder/L -> a/.. with holder/a -> root/objects: the operating system follows a before
+ /// it climbs, so holder/L is root — while collapsing the target as text would call it holder.
+ ///
+ private string DotDotThroughLink(string root)
+ {
+ var holder = NamespaceRoot();
+ Directory.CreateSymbolicLink(Path.Combine(holder, "a"), Path.Combine(root, "objects"));
+ Directory.CreateSymbolicLink(Path.Combine(holder, "L"), Path.Combine("a", ".."));
+ return Path.Combine(holder, "L");
+ }
+
+ /// A root of the collector's own whose is a link into : the root itself names nothing shared.
+ private string LinkBeneath(string root, string beneath)
+ {
+ var collectorRoot = NamespaceRoot();
+ var link = Path.Combine(collectorRoot, beneath);
+ Directory.CreateDirectory(Path.GetDirectoryName(link)!);
+ Directory.CreateSymbolicLink(link, Path.Combine(root, beneath));
+ return collectorRoot;
+ }
+
+ private string LinkTo(string root)
+ {
+ var link = Path.Combine(Path.GetTempPath(), "codespace-namespace-reaper", $"link-{Guid.NewGuid():N}");
+ Directory.CreateSymbolicLink(link, root);
+ _namespaceRoots.Add(link);
+ return link;
+ }
+
+ /// Content unique to the call, past nothing in particular: a routed row's placement is what is under test, not its size.
+ private static byte[] RoutedBytes(string content) => System.Text.Encoding.UTF8.GetBytes($"{content} {Guid.NewGuid():N}");
+
+ /// The key production gives these bytes (ArtifactStore.ObjectKeyFor): content-addressed, with no team segment.
+ private static string ObjectKeyOf(byte[] bytes)
+ {
+ var sha = Convert.ToHexStringLower(SHA256.HashData(bytes));
+ return $"workflow-artifacts/{sha[..2]}/{sha.Substring(2, 2)}/{sha}";
+ }
+
+ /// Writes the object where the local-rwx driver keeps it: <root>/objects/<key>.
+ private static string PlaceObject(string root, byte[] bytes)
+ {
+ var path = Path.Combine([root, "objects", .. ObjectKeyOf(bytes).Split('/')]);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ private static string RootConfig(string rootPath) => JsonSerializer.Serialize(new { rootPath });
+
+ ///
+ /// What admission stored before namespaces were canonical: the hash of the configuration as typed. Every revision
+ /// already in a database carries this, so it is the fixture the sweep has to be right about.
+ ///
+ private static string StoredBeforeCanonicalAdmission(string providerTypeKey, string config)
+ {
+ using var document = JsonDocument.Parse(config);
+ return StorageProfileRules.NamespaceFingerprint(providerTypeKey, document.RootElement);
+ }
+
+ ///
+ /// One team's routed artifact: an Active profile revision, the team's own artifact_object for the digest, the
+ /// pointing row, and an Available location at the production key. Each team owns its own object row for the same
+ /// bytes, exactly as two independent routed writes produce.
+ ///
+ private async Task SeedPlacementAsync(World world, byte[] bytes, string providerTypeKey, string config)
+ {
+ using var scope = _fixture.BeginScope();
+ var db = scope.Resolve();
+ var now = DateTimeOffset.UtcNow;
+ var digest = SHA256.HashData(bytes);
+ var profileId = Guid.NewGuid();
+ var revisionId = Guid.NewGuid();
+ var objectId = Guid.NewGuid();
+ var artifactId = Guid.NewGuid();
+ var locationId = Guid.NewGuid();
+ var key = ObjectKeyOf(bytes);
+
+ db.StorageProfile.Add(new StorageProfile
+ {
+ Id = profileId, TeamId = world.TeamId, StableName = $"namespace-probe-{profileId:N}", CurrentRevision = 1,
+ State = StorageProfileState.Active, CreatedDate = now, CreatedBy = world.ActorId, LastModifiedDate = now, LastModifiedBy = world.ActorId,
+ Revisions =
+ {
+ new StorageProfileRevision
+ {
+ Id = revisionId, TeamId = world.TeamId, StorageProfileId = profileId, Revision = 1, ProviderTypeKey = providerTypeKey,
+ NonSecretConfigJson = config, CredentialRef = null, NamespaceFingerprint = StoredBeforeCanonicalAdmission(providerTypeKey, config),
+ CreatedDate = now, CreatedBy = world.ActorId,
+ },
+ },
+ });
+ db.ArtifactObject.Add(new ArtifactObject
+ {
+ Id = objectId, TeamId = world.TeamId, DigestAlgorithm = ArtifactDigestAlgorithm.Sha256, Digest = digest,
+ SizeBytes = bytes.Length, CreatedDate = now, CreatedBy = world.ActorId,
+ });
+ db.WorkflowArtifact.Add(new WorkflowArtifact
+ {
+ Id = artifactId, TeamId = world.TeamId, Sha256 = Convert.ToHexStringLower(digest), ContentType = "application/octet-stream",
+ SizeBytes = bytes.Length, InlineBytes = null, StorageUrl = null, CasArtifactObjectId = objectId, CreatedAt = now,
+ });
+ await db.SaveChangesAsync();
+
+ // The location and its event go in ONE SaveChanges: every location revision needs its byte-identical event.
+ db.ArtifactLocationEvent.Add(new ArtifactLocationEvent
+ {
+ Id = Guid.NewGuid(), TeamId = world.TeamId, ArtifactLocationId = locationId, Revision = 1,
+ EventType = ArtifactLocationEventType.Created, State = ArtifactLocationState.Available, ObservedAt = now,
+ ProviderChecksumAlgorithm = "Sha256", ProviderChecksum = digest, ObservedSizeBytes = bytes.Length, VerifiedAt = now, CreatedBy = world.ActorId,
+ });
+ db.ArtifactLocation.Add(new ArtifactLocation
+ {
+ Id = locationId, TeamId = world.TeamId, ArtifactObjectId = objectId, StorageProfileRevisionId = revisionId,
+ Locator = key, ObjectKey = key, State = ArtifactLocationState.Available, Revision = 1,
+ ObservedSizeBytes = bytes.Length, ProviderChecksumAlgorithm = "Sha256", ProviderChecksum = digest, VerifiedAt = now,
+ CreatedDate = now, CreatedBy = world.ActorId, LastModifiedDate = now, LastModifiedBy = world.ActorId,
+ });
+ await db.SaveChangesAsync();
+
+ return new SeededPlacement(artifactId, locationId, revisionId);
+ }
+
+ /// The declaration IArtifactRetentionWriter mints on a first routed write, planted beside a seeded row.
+ private async Task DeclarePlacementAsync(World world, Guid artifactId)
+ {
+ using var scope = _fixture.BeginScope();
+ var db = scope.Resolve();
+ var now = DateTimeOffset.UtcNow;
+ db.WorkflowArtifactRetention.Add(new WorkflowArtifactRetention
+ {
+ ArtifactId = artifactId, TeamId = world.TeamId, RetentionClass = nameof(ArtifactRetentionClass.ArtifactManifestContent),
+ HolderKind = "artifact_manifest", HolderId = world.AgentRunId, State = ArtifactRetentionState.Declared,
+ DeclaredAt = now, NextSweepAt = now, Revision = 1, LastModifiedAt = now,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ private async Task StoredFingerprintAsync(SeededPlacement placement)
+ {
+ using var scope = _fixture.BeginScope();
+
+ return await scope.Resolve().StorageProfileRevision.AsNoTracking()
+ .Where(revision => revision.Id == placement.RevisionId).Select(revision => revision.NamespaceFingerprint).SingleAsync();
+ }
+
+ private async Task LocationStateAsync(Guid locationId)
+ {
+ using var scope = _fixture.BeginScope();
+
+ return await scope.Resolve().ArtifactLocation.AsNoTracking()
+ .Where(location => location.Id == locationId).Select(location => location.State).SingleAsync();
+ }
+
+ public void Dispose()
+ {
+ foreach (var path in Enumerable.Reverse(_namespaceRoots))
+ {
+ try
+ {
+ if (new FileInfo(path).LinkTarget != null) File.Delete(path);
+ else if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.cs
index 1e163ef79..6226896c6 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ArtifactRetentionReaperFlowTests.cs
@@ -14,6 +14,7 @@
using Shouldly;
using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
using System.Text.Json;
namespace CodeSpace.IntegrationTests.Workflows;
@@ -29,7 +30,7 @@ namespace CodeSpace.IntegrationTests.Workflows;
///
[Collection(PostgresCollection.Name)]
[Trait("Category", "Integration")]
-public sealed class ArtifactRetentionReaperFlowTests
+public sealed partial class ArtifactRetentionReaperFlowTests
{
private readonly PostgresFixture _fixture;
@@ -670,7 +671,7 @@ private async Task SweepWithRefusedPurgeAsync()
{
using var scope = _fixture.BeginScope();
var reaper = new ArtifactRetentionReaper(scope.Resolve>(), scope.Resolve(),
- new RefusingPurgeBackend(scope.Resolve()), scope.Resolve(), NullLogger.Instance);
+ new RefusingPurgeBackend(scope.Resolve()), scope.Resolve(), scope.Resolve(), NullLogger.Instance);
return await reaper.SweepAsync(CancellationToken.None);
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/ArtifactStoreRoutedDestinationFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/ArtifactStoreRoutedDestinationFlowTests.cs
index 8de3290c3..e80b2faf9 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/ArtifactStoreRoutedDestinationFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/ArtifactStoreRoutedDestinationFlowTests.cs
@@ -838,7 +838,7 @@ public async Task A_reference_committed_after_the_location_claim_is_seen_before_
var routed = new ReferenceAfterClaimPurgeCoordinator(sweepScope.Resolve(),
token => ReferenceFromAgentRunAsync(agentRunId, artifactId, token));
var reaper = new ArtifactRetentionReaper(sweepScope.Resolve>(), sweepScope.Resolve(),
- sweepScope.Resolve(), routed, NullLogger.Instance);
+ sweepScope.Resolve(), routed, sweepScope.Resolve(), NullLogger.Instance);
summary = await reaper.SweepAsync(CancellationToken.None);
}
@@ -923,7 +923,7 @@ public async Task An_uncertain_provider_delete_stays_live_and_the_next_sweep_rec
{
var routed = new UncertainDeletePurgeCoordinator(uncertainScope.Resolve());
var reaper = new ArtifactRetentionReaper(uncertainScope.Resolve>(), uncertainScope.Resolve(),
- uncertainScope.Resolve(), routed, NullLogger.Instance);
+ uncertainScope.Resolve(), routed, uncertainScope.Resolve(), NullLogger.Instance);
(await reaper.SweepAsync(CancellationToken.None)).Retried.ShouldBeGreaterThanOrEqualTo(1);
routed.ReleaseCalls.ShouldBe(0, "an effect-uncertain result must never be relabeled Available");
}
@@ -1549,7 +1549,7 @@ private async Task OrphanThePhysicalClaimAsync(Guid teamId, Guid objectId,
/// The shipped reaper over a substituted purge coordinator, with a short attempt budget so exhausting it is two sweeps rather than eight.
private static ArtifactRetentionReaper BudgetedReaper(ILifetimeScope scope, IArtifactCasPurgeCoordinator routed, int attempts) =>
new(new ArtifactRetentionReaperServices(scope.Resolve>(), scope.Resolve(),
- scope.Resolve(), routed, NullLogger.Instance),
+ scope.Resolve(), routed, scope.Resolve(), NullLogger.Instance),
new ArtifactRetentionReaperOptions(BatchSize: 200, ClaimSize: 25, MaxAttempts: attempts, LeaseDuration: TimeSpan.FromSeconds(60),
OperationTimeout: TimeSpan.FromSeconds(15), RetryDelay: TimeSpan.FromMinutes(30)));
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/LegacyPlacementAdoptionTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/LegacyPlacementAdoptionTests.cs
index 2ab85bb0d..a0f12132b 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/LegacyPlacementAdoptionTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/LegacyPlacementAdoptionTests.cs
@@ -121,7 +121,7 @@ private async Task RetentionClosesSidecarAsync()
var blobs = new CountingBlobBackend(scope.Resolve(), originalUrl);
var routed = new TrackingCasPurgeCoordinator(scope.Resolve(), placement.ArtifactObjectId);
var reaper = new ArtifactRetentionReaper(scope.Resolve>(), scope.Resolve(),
- blobs, routed, NullLogger.Instance);
+ blobs, routed, scope.Resolve(), NullLogger.Instance);
await SweepUntilAsync(reaper, retained.ArtifactId, ArtifactRetentionState.Quarantined);
await AgeQuarantineAsync(retained.ArtifactId, TimeSpan.FromDays(2));
@@ -645,7 +645,7 @@ private async Task RetentionRaceCoreAsync()
{
var backend = new BlockingAfterDeleteBlobBackend(reaperScope.Resolve(), deletion);
var reaper = new ArtifactRetentionReaper(reaperScope.Resolve>(), reaperScope.Resolve(),
- backend, reaperScope.Resolve(), NullLogger.Instance);
+ backend, reaperScope.Resolve(), reaperScope.Resolve(), NullLogger.Instance);
sweep = reaper.SweepAsync(CancellationToken.None);
await deletion.Started.Task.WaitAsync(TimeSpan.FromSeconds(10));
File.Exists(new Uri(retained.StorageUrl).LocalPath).ShouldBeFalse("the reaper has removed bytes while its retention UPDATE lock is still held");
@@ -760,7 +760,7 @@ private async Task WitnessRetentionRaceCoreAsync()
{
var backend = new BlockingAfterDeleteBlobBackend(reaperScope.Resolve(), deletion);
var reaper = new ArtifactRetentionReaper(reaperScope.Resolve>(), reaperScope.Resolve(),
- backend, reaperScope.Resolve(), NullLogger.Instance);
+ backend, reaperScope.Resolve(), reaperScope.Resolve(), NullLogger.Instance);
sweep = reaper.SweepAsync(CancellationToken.None);
await deletion.Started.Task.WaitAsync(TimeSpan.FromSeconds(10));
File.Exists(new Uri(retained.StorageUrl).LocalPath).ShouldBeFalse("retention removed the behind-cursor witness while its declaration lock remains held");
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/Runtime/ArtifactLocationVerifierFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/Runtime/ArtifactLocationVerifierFlowTests.cs
index b4e04b9df..d14b8daf6 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/Runtime/ArtifactLocationVerifierFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Artifacts/Runtime/ArtifactLocationVerifierFlowTests.cs
@@ -42,10 +42,8 @@ public async Task An_object_that_is_still_there_is_confirmed_and_its_verified_at
var world = await SeedStoredArtifactAsync();
var before = (await LocationAsync(world)).VerifiedAt.ShouldNotBeNull();
- var summary = await VerifyAsync();
+ var location = await VerifyUntilAsync(world, seen => seen.VerifiedAt > before, "re-verified");
- summary.Checked.ShouldBeGreaterThanOrEqualTo(1);
- var location = await LocationAsync(world);
location.State.ShouldBe(ArtifactLocationState.Available);
location.VerifiedAt.ShouldNotBeNull().ShouldBeGreaterThan(before);
}
@@ -58,9 +56,8 @@ public async Task An_object_the_destination_no_longer_holds_is_marked_missing_an
var world = await SeedStoredArtifactAsync();
DeleteStoredObject(world);
- await VerifyAsync();
+ var location = await VerifyUntilAsync(world, ArtifactLocationState.Missing);
- var location = await LocationAsync(world);
location.State.ShouldBe(ArtifactLocationState.Missing);
location.LastErrorCode.ShouldBe("location-object-missing");
location.LastErrorMessage.ShouldNotBeNullOrWhiteSpace("a demoted location must say what the destination answered");
@@ -79,9 +76,8 @@ public async Task An_object_replaced_by_something_else_is_marked_corrupt_rather_
var world = await SeedStoredArtifactAsync();
OverwriteStoredObject(world, "a different object entirely");
- await VerifyAsync();
+ var location = await VerifyUntilAsync(world, ArtifactLocationState.Corrupt);
- var location = await LocationAsync(world);
location.State.ShouldBe(ArtifactLocationState.Corrupt);
location.LastErrorCode.ShouldBe("location-object-mismatch");
}
@@ -133,8 +129,7 @@ public async Task A_location_wrongly_demoted_by_a_destination_fault_comes_back_w
// Moved rather than rewritten, so the object comes back byte-identical AND metadata-identical — the shape of a
// destination that was briefly serving an incomplete view of itself, not of someone editing the bytes.
File.Move(path, stashed);
- await VerifyAsync();
- (await LocationAsync(world)).State.ShouldBe(ArtifactLocationState.Missing);
+ await VerifyUntilAsync(world, ArtifactLocationState.Missing);
File.Move(stashed, path);
@@ -155,9 +150,7 @@ public async Task A_corrupt_location_is_not_swept_back_into_circulation()
// Missing it carries no risk of being a false demotion, and re-reading it would only invite flapping.
var world = await SeedStoredArtifactAsync();
OverwriteStoredObject(world, "a different object entirely");
- await VerifyAsync();
- (await LocationAsync(world)).State.ShouldBe(ArtifactLocationState.Corrupt);
- var demoted = await LocationAsync(world);
+ var demoted = await VerifyUntilAsync(world, ArtifactLocationState.Corrupt);
await VerifyAsync();
@@ -351,30 +344,45 @@ await scope.Resolve().Database.ExecuteSqlInterpolatedAsync(
/// would be asserting that this row won a race it has no reason to win — and would get harder to satisfy as the
/// suite grows.
///
- private async Task VerifyUntilAsync(StoredArtifact world, ArtifactLocationState state, TimeSpan? within = null)
+ private Task VerifyUntilAsync(StoredArtifact world, ArtifactLocationState state, TimeSpan? within = null) => VerifyUntilAsync(world, seen => seen.State == state, $"in state {state}", within);
+
+ ///
+ /// Sweeps until THIS test's location satisfies . A row written moments ago is freshly
+ /// verified, so every location an earlier test left Available sorts ahead of it; one pass reaches it only while
+ /// fewer of those exist than the batch holds, which is a property of the suite, not of the verifier.
+ ///
+ private async Task VerifyUntilAsync(StoredArtifact world, Func settled, string expectation, TimeSpan? within = null)
{
var deadline = DateTimeOffset.UtcNow + (within ?? TimeSpan.FromSeconds(10));
ArtifactLocation? seen = null;
+ ArtifactLocationVerificationSummary? pass = null;
while (DateTimeOffset.UtcNow < deadline)
{
- await VerifyAsync();
+ pass = await VerifyAsync();
seen = await LocationAsync(world);
- if (seen.State == state) return seen;
+ if (settled(seen)) return seen;
}
throw new Xunit.Sdk.XunitException(
- $"The location for artifact {world.ArtifactId} never reached {state} (last seen {seen?.State.ToString() ?? "absent"}, "
- + $"last error {seen?.LastErrorCode ?? "none"}). The sweep is deployment-wide and its recovery share is bounded, "
- + "so check whether earlier tests left more Missing rows than that share holds.");
+ $"The location for artifact {world.ArtifactId} was never {expectation} (last seen {seen?.State.ToString() ?? "absent"}, "
+ + $"verified at {seen?.VerifiedAt?.ToString("O") ?? "never"}, last error {seen?.LastErrorCode ?? "none"}). The last pass checked "
+ + $"{pass?.Checked}: {pass?.Confirmed} confirmed, {pass?.Inconclusive} inconclusive, {pass?.Skipped} skipped. The sweep is "
+ + "deployment-wide and bounded, so check whether earlier tests left more rows ahead of this one than a batch holds.");
}
+ ///
+ /// One pass at the verifier's own ceiling. A freshly written row sorts behind every row an earlier test left
+ /// Available, and a row whose destination that test deleted on cleanup never answers, so it never moves and holds
+ /// its place ahead of this one in every pass. The suite leaves dozens of those behind; a pass smaller than that
+ /// never reaches this test's row however often it runs.
+ ///
private async Task VerifyAsync()
{
using var scope = _fixture.BeginScope();
- return await scope.Resolve().VerifyStaleAsync(100, CancellationToken.None);
+ return await scope.Resolve().VerifyStaleAsync(500, CancellationToken.None);
}
/// Runs the real verifier with an observer around only this test's exact destination.
diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/ArtifactRetentionPolicyTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/ArtifactRetentionPolicyTests.cs
index 56b8eba91..cf3719bb6 100644
--- a/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/ArtifactRetentionPolicyTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/ArtifactRetentionPolicyTests.cs
@@ -5,6 +5,7 @@
using CodeSpace.Core.Services.Agents.Publish;
using CodeSpace.Core.Services.Workflows.Artifacts;
using CodeSpace.Core.Services.Workflows.Artifacts.Backends;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
using CodeSpace.Core.Services.Workflows.Artifacts.Retention;
using CodeSpace.Core.Services.Workflows.Artifacts.Runtime;
using CodeSpace.Core.Services.Workflows.Runtime;
@@ -67,13 +68,13 @@ public void An_unbounded_reaper_configuration_is_rejected_at_construction(int ba
var options = new ArtifactRetentionReaperOptions(batch, claim, attempts, TimeSpan.FromSeconds(leaseSeconds), TimeSpan.FromSeconds(operationSeconds), TimeSpan.FromMinutes(retryMinutes));
Should.Throw(() => new ArtifactRetentionReaper(new ArtifactRetentionReaperServices(
- DbOptions(), Oracle(), Blobs(), Purge(), NullLogger.Instance), options));
+ DbOptions(), Oracle(), Blobs(), Purge(), Modules(), NullLogger.Instance), options));
}
[Fact]
public void The_shipped_reaper_configuration_is_accepted()
{
- Should.NotThrow(() => new ArtifactRetentionReaper(DbOptions(), Oracle(), Blobs(), Purge(), NullLogger.Instance));
+ Should.NotThrow(() => new ArtifactRetentionReaper(DbOptions(), Oracle(), Blobs(), Purge(), Modules(), NullLogger.Instance));
}
[Fact]
@@ -172,6 +173,8 @@ private static DbContextOptions DbOptions() =>
private static IArtifactCasPurgeCoordinator Purge() => new RefusingCasPurgeCoordinator();
+ private static IStorageProviderModuleCatalog Modules() => new StorageProviderModuleCatalog([]);
+
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Profiles/StorageNamespaceIdentityTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Profiles/StorageNamespaceIdentityTests.cs
new file mode 100644
index 000000000..e82a1f65b
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Profiles/StorageNamespaceIdentityTests.cs
@@ -0,0 +1,130 @@
+using System.Text.Json;
+using Autofac;
+using CodeSpace.Core;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.AliyunOss;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+using Microsoft.Extensions.Configuration;
+using Serilog;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Workflows.Artifacts.Profiles;
+
+///
+/// How the object a stored placement reaches is identified when something has to COMPARE two of them — the retention
+/// reaper deciding whether one object key, under two teams' profile revisions, is one physical object.
+///
+/// The rule under test is the direction of every failure. An identity that cannot be derived is not "different
+/// from everything": it is unknown, and the caller must read unknown as "possibly the same", because the only cost of
+/// that reading is a kept byte and the cost of the other is another team's deleted one.
+///
+[Trait("Category", "Unit")]
+public sealed class StorageNamespaceIdentityTests
+{
+ private static readonly IStorageProviderModuleCatalog Catalog = new StorageProviderModuleCatalog([new LocalRwxStorageProviderModule(), new AliyunOssStorageProviderModule(), new ThrowingProjectionModule(), new ScalarProjectionModule()]);
+
+ private const string Key = "workflow-artifacts/ab/cd/abcd0000000000000000000000000000000000000000000000000000000000ff";
+
+ [Fact]
+ public void A_filesystem_revision_derives_the_physical_object_its_key_reaches()
+ {
+ using var config = JsonDocument.Parse("""{"rootPath":"/srv/codespace/artifacts/"}""");
+
+ StorageProfileRules.TryObjectIdentity(Catalog, "local-rwx/v1", config.RootElement.GetRawText(), Key)
+ .ShouldBe(new LocalRwxStorageProviderModule().PhysicalObject(config.RootElement, Key));
+ }
+
+ [Fact]
+ public void A_revision_whose_provider_cannot_name_the_object_is_identified_by_its_namespace_and_the_key()
+ {
+ // An object store's key IS the object once the bucket and prefix are known, so the namespace admission
+ // fingerprints and the key together name it.
+ const string config = """{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""";
+ using var document = JsonDocument.Parse(config);
+
+ StorageProfileRules.TryObjectIdentity(Catalog, "aliyun-oss/v1", config, Key)
+ .ShouldBe($"{StorageProfileRules.NamespaceFingerprint(new AliyunOssStorageProviderModule(), document.RootElement)}/{Key}");
+ }
+
+ [Fact]
+ public void Two_revisions_that_spell_one_root_differently_derive_one_identity() =>
+ StorageProfileRules.TryObjectIdentity(Catalog, "local-rwx/v1", """{"rootPath":"/srv/codespace/artifacts/."}""", Key)
+ .ShouldBe(StorageProfileRules.TryObjectIdentity(Catalog, "local-rwx/v1", """{"rootPath":"/srv/codespace/artifacts"}""", Key));
+
+ [Theory]
+ [InlineData("retired-provider/v1", """{"rootPath":"/srv/codespace/artifacts"}""", Key)]
+ [InlineData("local-rwx/v1", """{"rootPath":""", Key)]
+ [InlineData("local-rwx/v1", """{"unexpected":"/srv/codespace/artifacts"}""", Key)]
+ [InlineData("local-rwx/v1", """{"rootPath":42}""", Key)]
+ [InlineData("local-rwx/v1", """{"rootPath":"/srv/codespace/artifacts"}""", "workflow-artifacts/../../escape")]
+ [InlineData("aliyun-oss/v1", """{"endpoint":"oss-accelerate.aliyuncs.com","bucket":"codespace-artifacts"}""", Key)]
+ [InlineData("throwing-projection/v1", """{}""", Key)]
+ [InlineData("scalar-projection/v1", """{}""", Key)]
+ [InlineData("", """{}""", Key)]
+ public void A_revision_whose_identity_cannot_be_derived_has_none_rather_than_an_invented_one(string providerTypeKey, string config, string objectKey) =>
+ StorageProfileRules.TryObjectIdentity(Catalog, providerTypeKey, config, objectKey).ShouldBeNull();
+
+ [Fact]
+ public void Every_shipped_storage_module_owns_its_namespace_projection()
+ {
+ // The interface's default projection is the raw configuration, which is exactly the comparison that let two
+ // spellings of one root read as two namespaces. A provider that ships without its own projection inherits that
+ // defect, so adding one is required to be a decision rather than an omission.
+ using var container = Container();
+
+ var inheriting = container.Resolve().Modules
+ .Where(module => ProjectionOwner(module) == typeof(IStorageProviderModule))
+ .Select(module => module.TypeKey)
+ .ToList();
+
+ inheriting.ShouldBeEmpty("these modules compare namespaces by the text an operator typed, not the place it reaches");
+ }
+
+ private static Type ProjectionOwner(IStorageProviderModule module)
+ {
+ var map = module.GetType().GetInterfaceMap(typeof(IStorageProviderModule));
+ var slot = Array.FindIndex(map.InterfaceMethods, method => method.Name == nameof(IStorageProviderModule.GetNamespaceConfiguration));
+
+ return map.TargetMethods[slot].DeclaringType!;
+ }
+
+ private static IContainer Container()
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["ASPNETCORE_ENVIRONMENT"] = "Development",
+ ["CodeSpaceStore:ConnectionString"] = "Host=unused;Database=unused"
+ }).Build();
+ var builder = new ContainerBuilder();
+ builder.RegisterModule(new CodeSpaceModule(new LoggerConfiguration().CreateLogger(), configuration));
+ return builder.Build();
+ }
+
+ /// A provider whose projection fails for a reason no caller can name in advance.
+ private sealed class ThrowingProjectionModule : TestModule
+ {
+ public override string TypeKey => "throwing-projection/v1";
+ public override JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => throw new IOException("the namespace could not be inspected");
+ }
+
+ /// A provider whose projection breaks the contract's shape: an identity must be an object.
+ private sealed class ScalarProjectionModule : TestModule
+ {
+ public override string TypeKey => "scalar-projection/v1";
+ public override JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => JsonDocument.Parse("\"flat\"").RootElement.Clone();
+ }
+
+ private abstract class TestModule : IStorageProviderModule
+ {
+ private static readonly JsonElement Schema = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
+
+ public abstract string TypeKey { get; }
+ public string DisplayName => TypeKey;
+ public JsonElement ConfigSchema => Schema;
+ public JsonElement SecretSchema => Schema;
+ public StorageProviderCapabilities Capabilities => StorageProviderCapabilities.StreamingRead;
+ public Type FactoryType => typeof(LocalRwxArtifactStorageDriverFactory);
+ public abstract JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration);
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageNamespaceTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageNamespaceTests.cs
new file mode 100644
index 000000000..9507a774f
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/AliyunOss/AliyunOssStorageNamespaceTests.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.AliyunOss;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Workflows.Artifacts.Providers.AliyunOss;
+
+///
+/// The namespace identity of an OSS destination is the bucket and key prefix it reaches, not the route it reaches them
+/// by. The public host, the VPC host and the accelerate host of one region all address the same bucket, so two profiles
+/// that spell the route differently still share every object key — and object keys carry no team segment.
+///
+[Trait("Category", "Unit")]
+public sealed class AliyunOssStorageNamespaceTests
+{
+ private const string Canonical = """{"bucket":"codespace-artifacts","endpoint":"oss-cn-hangzhou.aliyuncs.com","keyPrefix":"team-7/"}""";
+
+ [Theory]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"https://oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou-internal.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"https://oss-cn-hangzhou-internal.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss-accelerate.aliyuncs.com","region":"cn-hangzhou","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss-accelerate-overseas.aliyuncs.com","region":"cn-hangzhou","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","region":"cn-hangzhou","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ public void Every_route_to_one_bucket_and_prefix_projects_to_one_namespace(string spelling) =>
+ Projection(spelling).ShouldBe(Canonical);
+
+ [Fact]
+ public void An_empty_key_prefix_is_the_bucket_root_and_so_is_no_key_prefix_at_all() =>
+ Projection("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":""}""")
+ .ShouldBe(Projection("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts"}"""));
+
+ [Theory]
+ [InlineData("""{"endpoint":"https://files.example.com","region":"cn-hangzhou","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss.example.internal","region":"cn-hangzhou","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ public void A_host_outside_the_service_domain_is_one_namespace_with_the_bucket_of_the_region_it_signs_for(string spelling) =>
+ // A custom domain or proxy cannot say which bucket it fronts, and where the identity cannot tell two routes
+ // apart it must merge them: OSS bucket names are unique service-wide and every request is signed for the
+ // bucket's own region, so the only OSS bucket this route can reach is that region's bucket of the same name.
+ Projection(spelling).ShouldBe(Canonical);
+
+ [Theory]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"other-bucket","keyPrefix":"team-7/"}""")]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-9/"}""")]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/nested/"}""")]
+ [InlineData("""{"endpoint":"oss-cn-hangzhou.aliyuncs.com","bucket":"codespace-artifacts"}""")]
+ [InlineData("""{"endpoint":"oss-cn-shanghai.aliyuncs.com","bucket":"codespace-artifacts","keyPrefix":"team-7/"}""")]
+ public void A_different_bucket_prefix_or_region_stays_a_different_namespace(string other) =>
+ // The guard on the guard: an identity that merged these would keep every deduplicated byte forever.
+ Projection(other).ShouldNotBe(Canonical);
+
+ [Theory]
+ [InlineData("a//b/")]
+ [InlineData("./a/")]
+ [InlineData("a/./b/")]
+ [InlineData("a/../b/")]
+ [InlineData("/a/")]
+ [InlineData("a")]
+ public void A_key_prefix_the_driver_would_refuse_has_no_identity_rather_than_an_invented_one(string keyPrefix)
+ {
+ // OSS keys are literal, so 'a//b/' and 'a/b/' are different objects; the activation parser refuses both kinds of
+ // ambiguity outright rather than normalising them. The projection reuses that parser, so a revision carrying
+ // one has no identity at all — and a caller comparing identities must read that as "possibly the same".
+ using var config = JsonDocument.Parse(JsonSerializer.Serialize(new { endpoint = "oss-cn-hangzhou.aliyuncs.com", bucket = "codespace-artifacts", keyPrefix }));
+
+ Should.Throw(() => Module.GetNamespaceConfiguration(config.RootElement));
+ }
+
+ [Fact]
+ public void A_configuration_already_written_canonically_keeps_the_fingerprint_its_revision_stored()
+ {
+ // Hard-pinned: the value an aliyun-oss revision written this way has carried since the fingerprint was
+ // introduced. Revisions are immutable, so a change here silently re-means every one of them.
+ using var config = JsonDocument.Parse("""{"keyPrefix":"team-7/","bucket":"codespace-artifacts","endpoint":"oss-cn-hangzhou.aliyuncs.com"}""");
+
+ StorageProfileRules.NamespaceFingerprint(Module, config.RootElement).ShouldBe(StorageProfileRules.NamespaceFingerprint(Module.TypeKey, config.RootElement));
+ StorageProfileRules.NamespaceFingerprint(Module, config.RootElement).ShouldBe("sha256:a1c39ca50714a2497bf4b94f95aaae1fa2eace6c1674c11163def0f8c8b03261");
+ }
+
+ private static IStorageProviderModule Module { get; } = new AliyunOssStorageProviderModule();
+
+ private static string Projection(string config)
+ {
+ using var document = JsonDocument.Parse(config);
+
+ return StorageProfileRules.CanonicalJson(Module.GetNamespaceConfiguration(document.RootElement));
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/Local/LocalStorageNamespaceTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/Local/LocalStorageNamespaceTests.cs
new file mode 100644
index 000000000..868654e6a
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Workflows/Artifacts/Providers/Local/LocalStorageNamespaceTests.cs
@@ -0,0 +1,372 @@
+using System.Text.Json;
+using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
+using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local.Legacy;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Workflows.Artifacts.Providers.Local;
+
+///
+/// The namespace identity of a filesystem destination is the directory its root REACHES, not the text an operator
+/// typed. Every local driver opens its root through Path.GetFullPath and the operating system, so every spelling
+/// below lands on one directory — and object keys carry no team segment, so two profiles naming that directory share
+/// every object they both store. An identity that told two of these spellings apart let one team's retention sweep
+/// delete another team's live bytes.
+///
+[Trait("Category", "Unit")]
+public sealed class LocalStorageNamespaceTests : IDisposable
+{
+ private readonly string _scratch = Path.Combine(Path.GetTempPath(), "codespace-namespace-unit", Guid.NewGuid().ToString("N"));
+
+ public LocalStorageNamespaceTests() => Directory.CreateDirectory(_scratch);
+
+ public static TheoryData ModulesAndSpellings()
+ {
+ var data = new TheoryData();
+ foreach (var module in new[] { "local-rwx", "local-legacy" })
+ foreach (var spelling in new[] { "as-written", "trailing-slash", "dot-segment", "doubled-slash", "doubled-inner-slash", "parent-segment", "relative" })
+ data.Add(module, spelling);
+ return data;
+ }
+
+ [Theory]
+ [MemberData(nameof(ModulesAndSpellings))]
+ public void Every_spelling_of_an_existing_root_projects_to_one_namespace(string module, string spelling)
+ {
+ var root = Path.Combine(_scratch, "victim-root");
+ Directory.CreateDirectory(root);
+
+ Projection(module, Spell(root, spelling)).ShouldBe(Projection(module, root),
+ $"'{Spell(root, spelling)}' opens the very directory '{root}' does, so it must be the same namespace");
+ }
+
+ [Theory]
+ [MemberData(nameof(ModulesAndSpellings))]
+ public void Every_spelling_of_a_root_that_does_not_exist_yet_projects_to_one_namespace(string module, string spelling)
+ {
+ // The directory the first write creates is the same whichever spelling created it, so the identity cannot wait
+ // for the directory to exist before it agrees with itself.
+ var root = Path.Combine(_scratch, "not-created-yet", "artifacts");
+
+ Projection(module, Spell(root, spelling)).ShouldBe(Projection(module, root));
+ }
+
+ [Fact]
+ public void A_symbolic_link_to_a_root_is_that_root()
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ var root = Directory.CreateDirectory(Path.Combine(_scratch, "real-root")).FullName;
+ var link = Path.Combine(_scratch, "alias-root");
+ Directory.CreateSymbolicLink(link, root);
+
+ Projection("local-rwx", link).ShouldBe(Projection("local-rwx", root), "every byte written through the link lands in the directory it points at");
+ }
+
+ [Fact]
+ public void A_directory_beneath_a_linked_parent_that_does_not_exist_yet_is_beneath_the_link_target()
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ var parent = Directory.CreateDirectory(Path.Combine(_scratch, "real-parent")).FullName;
+ var link = Path.Combine(_scratch, "alias-parent");
+ Directory.CreateSymbolicLink(link, parent);
+
+ Projection("local-rwx", Path.Combine(link, "team-a")).ShouldBe(Projection("local-rwx", Path.Combine(parent, "team-a")));
+ }
+
+ [Fact]
+ public void A_relative_link_resolves_against_the_directory_that_holds_it()
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ var root = Directory.CreateDirectory(Path.Combine(_scratch, "relative-target")).FullName;
+ var holder = Directory.CreateDirectory(Path.Combine(_scratch, "holder")).FullName;
+ var link = Path.Combine(holder, "alias");
+ Directory.CreateSymbolicLink(link, Path.Combine("..", "relative-target"));
+
+ Projection("local-rwx", link).ShouldBe(Projection("local-rwx", root));
+ }
+
+ [Fact]
+ public void A_link_target_that_climbs_out_of_another_link_climbs_from_where_that_link_landed()
+ {
+ // The operating system follows 'a' BEFORE it applies the '..' after it, so 'holder/L' -> 'a/..' with
+ // 'holder/a' -> 'victim-root/objects' opens 'victim-root'. Collapsing 'a/..' as text first would name 'holder'
+ // instead — a directory nothing is written to — and let a sweep through L delete the victim's bytes.
+ if (OperatingSystem.IsWindows()) return;
+
+ var victim = Directory.CreateDirectory(Path.Combine(_scratch, "victim-root", "objects")).Parent!.FullName;
+ var holder = Directory.CreateDirectory(Path.Combine(_scratch, "holder")).FullName;
+ Directory.CreateSymbolicLink(Path.Combine(holder, "a"), Path.Combine(victim, "objects"));
+ var link = Path.Combine(holder, "L");
+ Directory.CreateSymbolicLink(link, Path.Combine("a", ".."));
+
+ File.WriteAllText(Path.Combine(victim, "marker"), "reached");
+ File.Exists(Path.Combine(link, "marker")).ShouldBeTrue("the precondition: the operating system opens the victim's root through L");
+
+ Projection("local-rwx", link).ShouldBe(Projection("local-rwx", victim));
+ }
+
+ [Fact]
+ public void A_link_cycle_has_no_identity_rather_than_an_invented_one()
+ {
+ // A root that never resolves cannot be compared with anything. Refusing it is what lets every caller fall back
+ // to treating it as possibly the same as anything else.
+ if (OperatingSystem.IsWindows()) return;
+
+ var first = Path.Combine(_scratch, "loop-a");
+ var second = Path.Combine(_scratch, "loop-b");
+ Directory.CreateSymbolicLink(first, second);
+ Directory.CreateSymbolicLink(second, first);
+
+ Should.Throw(() => Projection("local-rwx", Path.Combine(first, "artifacts")));
+ }
+
+ [Fact]
+ public void Two_different_directories_stay_two_namespaces()
+ {
+ // The guard on the guard: an identity that merged everything would keep every deduplicated byte forever.
+ Projection("local-rwx", Path.Combine(_scratch, "team-a")).ShouldNotBe(Projection("local-rwx", Path.Combine(_scratch, "team-b")));
+ }
+
+ [Fact]
+ public void Letter_case_is_folded_exactly_where_the_platform_filesystem_ignores_it()
+ {
+ var lower = Projection("local-rwx", Path.Combine(_scratch, "casing", "artifacts"));
+ var upper = Projection("local-rwx", Path.Combine(_scratch, "casing", "ARTIFACTS"));
+
+ if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) upper.ShouldBe(lower, "a case-insensitive filesystem opens one directory for both");
+ else upper.ShouldNotBe(lower, "Linux compares names byte for byte, so these are two directories");
+ }
+
+ [Theory]
+ [InlineData("local-rwx")]
+ [InlineData("local-legacy")]
+ public void The_projection_carries_the_root_and_nothing_else(string module)
+ {
+ var projection = JsonDocument.Parse(Projection(module, Path.Combine(_scratch, "only-root"))).RootElement;
+
+ projection.EnumerateObject().Select(property => property.Name).ShouldBe(["rootPath"]);
+ }
+
+ [Theory]
+ [InlineData("local-rwx")]
+ [InlineData("local-legacy")]
+ public void A_root_already_written_canonically_keeps_the_fingerprint_its_revision_stored(string module)
+ {
+ // Revisions are immutable and every one written before this projection existed stored the hash of its raw
+ // configuration. Projecting a canonical root to itself is what keeps those stored values true.
+ if (OperatingSystem.IsWindows()) return;
+
+ var subject = Module(module);
+ var canonical = Projection(module, Path.Combine(_scratch, "stable"));
+ using var stored = JsonDocument.Parse(canonical);
+
+ StorageProfileRules.NamespaceFingerprint(subject, stored.RootElement).ShouldBe(StorageProfileRules.NamespaceFingerprint(subject.TypeKey, stored.RootElement));
+ }
+
+ [Fact]
+ public void The_fingerprint_of_a_canonical_production_root_is_byte_identical_to_the_one_already_stored()
+ {
+ // Hard-pinned: this is the value a local-rwx revision naming /srv/codespace/artifacts has carried since the
+ // fingerprint was introduced. A change here is a change to every stored revision's meaning, not a refactor.
+ if (OperatingSystem.IsWindows()) return;
+
+ using var config = JsonDocument.Parse("""{"rootPath":"/srv/codespace/artifacts"}""");
+
+ StorageProfileRules.NamespaceFingerprint(new LocalRwxStorageProviderModule(), config.RootElement)
+ .ShouldBe("sha256:d8df91c032f0a9ea898c31eee26d176f30992458f399d57623dafe52e78c6a24");
+ }
+
+ [Theory]
+ [InlineData("""{}""")]
+ [InlineData("""{"rootPath":42}""")]
+ [InlineData("""{"rootPath":" "}""")]
+ [InlineData("""[]""")]
+ public void A_configuration_with_no_readable_root_has_no_identity(string config)
+ {
+ using var document = JsonDocument.Parse(config);
+
+ Should.Throw(() => Module("local-rwx").GetNamespaceConfiguration(document.RootElement));
+ Should.Throw(() => Module("local-legacy").GetNamespaceConfiguration(document.RootElement));
+ }
+
+ // ─── The physical object a key reaches ───────────────────────────────────
+
+ public static TheoryData ObjectReachingShapes() => ["as-written", "trailing-slash", "symlink-root", "dotdot-through-link", "objects-link", "nested-link"];
+
+ [Theory]
+ [MemberData(nameof(ObjectReachingShapes))]
+ public void Every_path_the_operating_system_follows_to_one_file_is_one_object(string shape)
+ {
+ // The driver opens /objects/ and the operating system follows a link ANYWHERE along it, so a root
+ // the collector owns outright can still reach the victim's file through a link planted beneath it.
+ if (OperatingSystem.IsWindows()) return;
+
+ var victim = Directory.CreateDirectory(Path.Combine(_scratch, "victim-root")).FullName;
+ var file = PlaceObject(victim);
+ var collector = Reach(victim, shape);
+
+ File.Exists(Path.Combine([collector, "objects", .. ObjectKey.Split('/')])).ShouldBeTrue($"the precondition: the operating system opens {file} through '{collector}'");
+
+ ObjectIdentity(collector).ShouldBe(ObjectIdentity(victim), $"'{collector}' reaches the very file '{victim}' does, so deleting either deletes both");
+ }
+
+ [Fact]
+ public void Every_spelling_of_a_root_whose_object_does_not_exist_yet_reaches_one_object()
+ {
+ var root = Path.Combine(_scratch, "not-created-yet", "artifacts");
+
+ ObjectIdentity(root + "/.").ShouldBe(ObjectIdentity(root));
+ ObjectIdentity(Path.GetRelativePath(Environment.CurrentDirectory, root)).ShouldBe(ObjectIdentity(root));
+ }
+
+ [Fact]
+ public void Two_roots_hold_two_objects_at_one_key()
+ {
+ // The guard on the guard: content addressing gives every team storing these bytes this key, so merging two real
+ // directories would keep every deduplicated byte forever.
+ var first = Directory.CreateDirectory(Path.Combine(_scratch, "team-a")).FullName;
+ var second = Directory.CreateDirectory(Path.Combine(_scratch, "team-b")).FullName;
+ PlaceObject(first);
+ PlaceObject(second);
+
+ ObjectIdentity(first).ShouldNotBe(ObjectIdentity(second));
+ }
+
+ [Fact]
+ public void A_key_that_is_itself_a_link_is_its_own_object()
+ {
+ // A delete unlinks the NAME it is given, so a key that is a link to the victim's file removes the link and
+ // leaves the file. Following it would merge two objects the driver keeps apart — the safe direction, but a
+ // reaper that could never collect a planted link would be a reaper anyone can stall.
+ if (OperatingSystem.IsWindows()) return;
+
+ var victim = Directory.CreateDirectory(Path.Combine(_scratch, "victim-root")).FullName;
+ var collector = Directory.CreateDirectory(Path.Combine(_scratch, "collector-root")).FullName;
+ var file = PlaceObject(victim);
+ var name = Path.Combine([collector, "objects", .. ObjectKey.Split('/')]);
+ Directory.CreateDirectory(Path.GetDirectoryName(name)!);
+ File.CreateSymbolicLink(name, file);
+
+ ObjectIdentity(collector).ShouldNotBe(ObjectIdentity(victim));
+ }
+
+ [Fact]
+ public async Task The_object_identity_names_the_file_the_real_driver_writes_and_deletes()
+ {
+ // Drift detector: the identity re-states the driver's layout (/objects/). If the driver ever moves
+ // its objects, this is the test that notices before a sweep compares two paths neither of which is real.
+ if (OperatingSystem.IsWindows()) return;
+
+ var victim = Directory.CreateDirectory(Path.Combine(_scratch, "victim-root", "objects")).Parent!.FullName;
+ var collector = Reach(victim, "objects-link");
+ await using var driver = new LocalRwxArtifactStorageDriver(collector);
+ await using var content = new MemoryStream("driver-written"u8.ToArray());
+
+ var put = await driver.PutAsync(new ArtifactStoragePutRequest(ObjectKey, content) { Condition = ArtifactStorageWriteCondition.CreateOnly }, CancellationToken.None);
+
+ put.IsSuccess.ShouldBeTrue(put.Error?.Message);
+
+ File.Exists(ObjectIdentity(victim)).ShouldBeTrue("the identity must name the file the driver wrote, through the link, into the victim's root");
+ (await driver.DeleteAsync(new ArtifactStorageDeleteRequest(ObjectKey), CancellationToken.None)).IsSuccess.ShouldBeTrue();
+ File.Exists(ObjectIdentity(victim)).ShouldBeFalse("and a delete through the collector's root removes exactly that file");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("/workflow-artifacts/aa/bb/object")]
+ [InlineData("workflow-artifacts/../../escape")]
+ [InlineData("./workflow-artifacts/object")]
+ [InlineData("workflow-artifacts/a\0b")]
+ public void A_key_the_driver_would_refuse_has_no_identity(string objectKey)
+ {
+ using var config = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath = _scratch }));
+
+ Should.Throw(() => new LocalRwxStorageProviderModule().PhysicalObject(config.RootElement, objectKey));
+ }
+
+ private const string ObjectKey = "workflow-artifacts/ab/cd/abcd0000000000000000000000000000000000000000000000000000000000ff";
+
+ /// Writes the object where the local-rwx driver keeps it: <root>/objects/<key>.
+ private static string PlaceObject(string root)
+ {
+ var file = Path.Combine([root, "objects", .. ObjectKey.Split('/')]);
+ Directory.CreateDirectory(Path.GetDirectoryName(file)!);
+ File.WriteAllText(file, "victim bytes");
+ return file;
+ }
+
+ /// A root the collector could name that the operating system resolves into 's objects.
+ private string Reach(string victim, string shape)
+ {
+ var collector = Path.Combine(_scratch, $"collector-{shape}");
+
+ switch (shape)
+ {
+ case "as-written": return victim;
+ case "trailing-slash": return victim + "/";
+ case "symlink-root":
+ Directory.CreateSymbolicLink(collector, victim);
+ return collector;
+ case "dotdot-through-link":
+ Directory.CreateDirectory(collector);
+ Directory.CreateSymbolicLink(Path.Combine(collector, "a"), Path.Combine(victim, "objects"));
+ Directory.CreateSymbolicLink(Path.Combine(collector, "L"), Path.Combine("a", ".."));
+ return Path.Combine(collector, "L");
+ case "objects-link":
+ Directory.CreateDirectory(collector);
+ Directory.CreateSymbolicLink(Path.Combine(collector, "objects"), Path.Combine(victim, "objects"));
+ return collector;
+ case "nested-link":
+ Directory.CreateDirectory(Path.Combine(collector, "objects"));
+ Directory.CreateSymbolicLink(Path.Combine(collector, "objects", "workflow-artifacts"), Path.Combine(victim, "objects", "workflow-artifacts"));
+ return collector;
+ default: throw new ArgumentOutOfRangeException(nameof(shape), shape, null);
+ }
+ }
+
+ private static string ObjectIdentity(string rootPath)
+ {
+ using var config = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath }));
+
+ return new LocalRwxStorageProviderModule().PhysicalObject(config.RootElement, ObjectKey);
+ }
+
+ private static string Spell(string root, string spelling) => spelling switch
+ {
+ "as-written" => root,
+ "trailing-slash" => root + "/",
+ "dot-segment" => root + "/.",
+ "doubled-slash" => root + "//",
+ "doubled-inner-slash" => Path.GetDirectoryName(root) + "//" + Path.GetFileName(root),
+ "parent-segment" => Path.Combine(root, "..", Path.GetFileName(root)),
+ "relative" => Path.GetRelativePath(Environment.CurrentDirectory, root),
+ _ => throw new ArgumentOutOfRangeException(nameof(spelling), spelling, null),
+ };
+
+ private static IStorageProviderModule Module(string module) => module switch
+ {
+ "local-rwx" => new LocalRwxStorageProviderModule(),
+ "local-legacy" => new LocalLegacyStorageProviderModule(),
+ _ => throw new ArgumentOutOfRangeException(nameof(module), module, null),
+ };
+
+ /// The canonical JSON of the module's namespace projection — the exact string the fingerprint hashes.
+ private static string Projection(string module, string rootPath)
+ {
+ using var config = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath }));
+
+ return StorageProfileRules.CanonicalJson(Module(module).GetNamespaceConfiguration(config.RootElement));
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_scratch, recursive: true); }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+}