diff --git a/src/ScadaLink.Transport/Import/BundleSessionStore.cs b/src/ScadaLink.Transport/Import/BundleSessionStore.cs
new file mode 100644
index 0000000..a647623
--- /dev/null
+++ b/src/ScadaLink.Transport/Import/BundleSessionStore.cs
@@ -0,0 +1,74 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Options;
+using ScadaLink.Commons.Interfaces.Transport;
+using ScadaLink.Commons.Types.Transport;
+
+namespace ScadaLink.Transport.Import;
+
+///
+/// In-memory implementation of backed by a
+/// . Sessions are evicted lazily
+/// at read time () and on-demand via ;
+/// there is no background timer.
+///
+/// TTL is supplied by the importer via ;
+/// this store does not impose its own. The injected
+/// is used purely to determine now when checking ExpiresAt, which
+/// keeps unit tests deterministic.
+///
+///
+/// The 3-strike unlock lockout is owned by
+/// (FailedUnlockAttempts / Locked); the store just hands out the
+/// shared session reference so the importer can mutate the counter in place.
+///
+///
+public sealed class BundleSessionStore : IBundleSessionStore
+{
+ private readonly ConcurrentDictionary _sessions = new();
+ private readonly TimeProvider _timeProvider;
+
+ // Options are accepted to honor the documented constructor contract and to
+ // be ready for future per-store knobs (e.g. max in-flight sessions). The
+ // current store does not read any field — TTL is on the session itself.
+ public BundleSessionStore(IOptions options, TimeProvider timeProvider)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ _ = options.Value;
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ }
+
+ public BundleSession Open(BundleSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ // Overwrite on collision is defensive: GUIDs are random so practical
+ // collisions don't happen, but if a caller reuses an id we always
+ // honor the latest Open call.
+ _sessions[session.SessionId] = session;
+ return session;
+ }
+
+ public BundleSession? Get(Guid sessionId)
+ {
+ if (!_sessions.TryGetValue(sessionId, out var session)) return null;
+ if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session;
+ _sessions.TryRemove(sessionId, out _);
+ return null;
+ }
+
+ public void Remove(Guid sessionId)
+ {
+ _sessions.TryRemove(sessionId, out _);
+ }
+
+ public void EvictExpired()
+ {
+ var now = _timeProvider.GetUtcNow();
+ foreach (var kv in _sessions)
+ {
+ if (kv.Value.ExpiresAt <= now)
+ {
+ _sessions.TryRemove(kv.Key, out _);
+ }
+ }
+ }
+}
diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
index 99700aa..bf0f7be 100644
--- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
@@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
+using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Transport.Export;
+using ScadaLink.Transport.Import;
namespace ScadaLink.Transport;
@@ -12,7 +15,9 @@ public static class ServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions().BindConfiguration(OptionsSection);
+ services.TryAddSingleton(TimeProvider.System);
services.AddScoped();
+ services.AddSingleton();
// Remaining concrete services added in later tasks.
return services;
}
diff --git a/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs b/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs
new file mode 100644
index 0000000..271de05
--- /dev/null
+++ b/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs
@@ -0,0 +1,136 @@
+using Microsoft.Extensions.Options;
+using ScadaLink.Commons.Types.Transport;
+using ScadaLink.Transport;
+using ScadaLink.Transport.Import;
+
+namespace ScadaLink.Transport.Tests.Import;
+
+public sealed class BundleSessionStoreTests
+{
+ private static IOptions Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions());
+
+ private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new()
+ {
+ SessionId = Guid.NewGuid(),
+ Manifest = StubManifest(),
+ DecryptedContent = Array.Empty(),
+ ExpiresAt = expiresAt,
+ };
+
+ private static BundleManifest StubManifest() => new(
+ BundleFormatVersion: 1,
+ SchemaVersion: "1.0",
+ CreatedAtUtc: DateTimeOffset.UnixEpoch,
+ SourceEnvironment: "test",
+ ExportedBy: "t",
+ ScadaLinkVersion: "1",
+ ContentHash: "0",
+ Encryption: null,
+ Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
+ Contents: Array.Empty());
+
+ [Fact]
+ public void Open_then_Get_returns_session()
+ {
+ var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
+ var sut = new BundleSessionStore(Options(), clock);
+ var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
+
+ sut.Open(session);
+
+ var fetched = sut.Get(session.SessionId);
+ Assert.NotNull(fetched);
+ Assert.Same(session, fetched);
+ }
+
+ [Fact]
+ public void Get_after_TTL_returns_null_and_evicts()
+ {
+ var start = DateTimeOffset.UtcNow;
+ var clock = new TestTimeProvider(start);
+ var sut = new BundleSessionStore(Options(), clock);
+ var session = SessionExpiringAt(start.AddMinutes(1));
+ sut.Open(session);
+
+ clock.Advance(TimeSpan.FromMinutes(2));
+
+ Assert.Null(sut.Get(session.SessionId));
+ // Lookup with no time advancement now returns null again — proves the
+ // entry was evicted, not merely filtered.
+ Assert.Null(sut.Get(session.SessionId));
+ }
+
+ [Fact]
+ public void Remove_evicts_session()
+ {
+ var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
+ var sut = new BundleSessionStore(Options(), clock);
+ var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1));
+ sut.Open(session);
+
+ sut.Remove(session.SessionId);
+
+ Assert.Null(sut.Get(session.SessionId));
+ }
+
+ [Fact]
+ public void EvictExpired_removes_all_past_ttl()
+ {
+ var start = DateTimeOffset.UtcNow;
+ var clock = new TestTimeProvider(start);
+ var sut = new BundleSessionStore(Options(), clock);
+
+ var keep = SessionExpiringAt(start.AddMinutes(30));
+ var dropA = SessionExpiringAt(start.AddSeconds(10));
+ var dropB = SessionExpiringAt(start.AddMinutes(1));
+ sut.Open(keep);
+ sut.Open(dropA);
+ sut.Open(dropB);
+
+ clock.Advance(TimeSpan.FromMinutes(2));
+ sut.EvictExpired();
+
+ Assert.NotNull(sut.Get(keep.SessionId));
+ Assert.Null(sut.Get(dropA.SessionId));
+ Assert.Null(sut.Get(dropB.SessionId));
+ }
+
+ [Fact]
+ public void Three_failed_unlock_attempts_locks_session()
+ {
+ var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
+ var sut = new BundleSessionStore(Options(), clock);
+ var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
+ sut.Open(session);
+
+ var live = sut.Get(session.SessionId);
+ Assert.NotNull(live);
+
+ live!.FailedUnlockAttempts++;
+ Assert.False(live.Locked);
+ live.FailedUnlockAttempts++;
+ Assert.False(live.Locked);
+ live.FailedUnlockAttempts++;
+ Assert.True(live.Locked);
+
+ // Mutations are observable via re-fetch — confirms the store hands out
+ // the shared reference (not a snapshot).
+ var refetch = sut.Get(session.SessionId);
+ Assert.NotNull(refetch);
+ Assert.True(refetch!.Locked);
+ }
+
+ ///
+ /// Minimal in-test with a mutable clock. Used in
+ /// place of Microsoft.Extensions.TimeProvider.Testing (not in the
+ /// central package list). Only is overridden; the
+ /// store does not use timers, so the base implementations suffice elsewhere.
+ ///
+ private sealed class TestTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+ public TestTimeProvider(DateTimeOffset start) { _now = start; }
+ public override DateTimeOffset GetUtcNow() => _now;
+ public void Advance(TimeSpan by) => _now = _now.Add(by);
+ }
+}