// Copyright (c) Microsoft. All rights reserved. using System; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.Checkpointing; namespace Microsoft.Agents.AI.Workflows.UnitTests; internal sealed class TempDirectory : IDisposable { public DirectoryInfo DirectoryInfo { get; } public TempDirectory() { string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); this.DirectoryInfo = Directory.CreateDirectory(tempDirPath); } public void Dispose() { this.DisposeInternal(); GC.SuppressFinalize(this); } private void DisposeInternal() { if (this.DirectoryInfo.Exists) { try { // Best efforts this.DirectoryInfo.Delete(recursive: true); } catch { } } } ~TempDirectory() { // Best efforts this.DisposeInternal(); } public static implicit operator DirectoryInfo(TempDirectory tempDirectory) => tempDirectory.DirectoryInfo; public string FullName => this.DirectoryInfo.FullName; public bool IsParentOf(FileInfo candidate) { if (candidate.Directory is null) { return false; } if (candidate.Directory.FullName == this.DirectoryInfo.FullName) { return true; } return this.IsParentOf(candidate.Directory); } public bool IsParentOf(DirectoryInfo candidate) { while (candidate.Parent is not null) { if (candidate.Parent.FullName == this.DirectoryInfo.FullName) { return true; } candidate = candidate.Parent; } return false; } } public sealed class FileSystemJsonCheckpointStoreTests { public static JsonElement TestData => JsonSerializer.SerializeToElement(new { test = "data" }); [Fact] public async Task CreateCheckpointAsync_ShouldPersistIndexToDiskBeforeDisposeAsync() { // Arrange using TempDirectory tempDirectory = new(); using FileSystemJsonCheckpointStore? store = new(tempDirectory); string runId = Guid.NewGuid().ToString("N"); // Act CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, TestData); // Assert - Check the file size before disposing to verify data was flushed to disk // The index.jsonl file is held exclusively by the store, so we check via FileInfo string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl"); FileInfo indexFile = new(indexPath); indexFile.Refresh(); long fileSizeBeforeDispose = indexFile.Length; // Data should already be on disk (file size > 0) before we dispose fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync"); // Dispose to release file lock before final verification store.Dispose(); string[] lines = File.ReadAllLines(indexPath); lines.Should().HaveCount(1); lines[0].Should().Contain(checkpoint.CheckpointId); } private async ValueTask Run_EscapeRootFolderTestAsync(string escapingPath) { // Arrange using TempDirectory tempDirectory = new(); using FileSystemJsonCheckpointStore store = new(tempDirectory); string naivePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, escapingPath); // Check that the naive path is actually outside the temp directory to validate the test is meaningful FileInfo naiveCheckpointFile = new(naivePath); tempDirectory.IsParentOf(naiveCheckpointFile).Should().BeFalse("The naive path should be outside the root folder to validate that escaping is necessary."); // Act CheckpointInfo checkpointInfo = await store.CreateCheckpointAsync(escapingPath, TestData); // Assert string naivePathWithCheckpointId = Path.Combine(tempDirectory.DirectoryInfo.FullName, $"{escapingPath}_{checkpointInfo.CheckpointId}.json"); new FileInfo(naivePathWithCheckpointId).Exists.Should().BeFalse("The naive path should not be used to save a checkpoint file."); string actualFileName = store.GetFileNameForCheckpoint(escapingPath, checkpointInfo); string actualFilePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, actualFileName); FileInfo actualFile = new(actualFilePath); tempDirectory.IsParentOf(actualFile).Should().BeTrue("The actual checkpoint should be saved inside the root folder."); actualFile.Exists.Should().BeTrue("The actual path should be used to save a checkpoint file."); } [Fact] public async Task CreateCheckpointAsync_ShouldNotEscapeRootFolderAsync() { // The SessionId is used as part of the file name, but if it contains path characters such as /.. it can escape the root folder. // Testing that such characters are escaped properly to prevent directory traversal attacks, etc. await this.Run_EscapeRootFolderTestAsync("../valid_suffix"); #if !NETFRAMEWORK if (OperatingSystem.IsWindows()) { // Windows allows both \ and / as path separators, so we test both await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix"); } #else // .NET Framework is always on Windows await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix"); #endif } private const string InvalidPathCharsWin32 = "\\/:*?\"<>|"; private const string InvalidPathCharsUnix = "/"; private const string InvalidPathCharsMacOS = "/:"; [Theory] [InlineData(InvalidPathCharsWin32)] [InlineData(InvalidPathCharsUnix)] [InlineData(InvalidPathCharsMacOS)] public async Task CreateCheckpointAsync_EscapesInvalidCharsAsync(string invalidChars) { // Arrange using TempDirectory tempDirectory = new(); using FileSystemJsonCheckpointStore store = new(tempDirectory); string runId = $"prefix_{invalidChars}_suffix"; Func createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData); await createCheckpointAction.Should().NotThrowAsync(); } [Fact] public async Task RetrieveCheckpointAsync_ShouldReturnPersistedDataAsync() { // Arrange using TempDirectory tempDirectory = new(); using FileSystemJsonCheckpointStore store = new(tempDirectory); string sessionId = Guid.NewGuid().ToString("N"); JsonElement originalData = JsonSerializer.SerializeToElement(new { name = "test", value = 42 }); // Act CheckpointInfo checkpoint = await store.CreateCheckpointAsync(sessionId, originalData); JsonElement retrieved = await store.RetrieveCheckpointAsync(sessionId, checkpoint); // Assert retrieved.GetProperty("name").GetString().Should().Be("test"); retrieved.GetProperty("value").GetInt32().Should().Be(42); } [Fact] public async Task RetrieveIndexAsync_ShouldOnlyReturnCheckpointsForRequestedSessionAsync() { // Arrange using TempDirectory tempDirectory = new(); string firstSessionId = Guid.NewGuid().ToString("N"); string secondSessionId = Guid.NewGuid().ToString("N"); CheckpointInfo firstCheckpoint; CheckpointInfo secondCheckpoint; using (FileSystemJsonCheckpointStore store = new(tempDirectory)) { firstCheckpoint = await store.CreateCheckpointAsync(firstSessionId, TestData); secondCheckpoint = await store.CreateCheckpointAsync(secondSessionId, TestData); // Act CheckpointInfo[] firstSessionIndex = (await store.RetrieveIndexAsync(firstSessionId)).ToArray(); // Assert firstSessionIndex.Should().ContainSingle().Which.Should().Be(firstCheckpoint); firstSessionIndex.Should().NotContain(secondCheckpoint); } using (FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory)) { CheckpointInfo[] secondSessionIndex = (await reopenedStore.RetrieveIndexAsync(secondSessionId)).ToArray(); secondSessionIndex.Should().ContainSingle().Which.Should().Be(secondCheckpoint); secondSessionIndex.Should().NotContain(firstCheckpoint); } } [Fact] public async Task RetrieveIndexAsync_ShouldFilterByParentCheckpointAsync() { // Arrange using TempDirectory tempDirectory = new(); string sessionId = Guid.NewGuid().ToString("N"); CheckpointInfo parentCheckpoint; CheckpointInfo childCheckpoint; CheckpointInfo unrelatedCheckpoint; using (FileSystemJsonCheckpointStore store = new(tempDirectory)) { parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData); childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint); unrelatedCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData); // Act CheckpointInfo[] childIndex = (await store.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray(); // Assert childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint); childIndex.Should().NotContain(parentCheckpoint); childIndex.Should().NotContain(unrelatedCheckpoint); } using (FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory)) { CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray(); childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint); childIndex.Should().NotContain(parentCheckpoint); childIndex.Should().NotContain(unrelatedCheckpoint); } } [Fact] public async Task RetrieveIndexAsync_ShouldKeepLegacyEntriesDiscoverableWithParentFilterAsync() { // Arrange using TempDirectory tempDirectory = new(); string sessionId = Guid.NewGuid().ToString("N"); CheckpointInfo parentCheckpoint; CheckpointInfo childCheckpoint; string childFileName; using (FileSystemJsonCheckpointStore store = new(tempDirectory)) { parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData); childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint); childFileName = store.GetFileNameForCheckpoint(sessionId, childCheckpoint); } string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl"); string legacyEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(childCheckpoint, childFileName)); File.WriteAllText(indexPath, legacyEntry + Environment.NewLine); // Act using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory); CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, parentCheckpoint)).ToArray(); // Assert childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint); } [Fact] public async Task RetrieveIndexAsync_ShouldKeepLegacyChildDiscoverableWithUnrelatedParentFilterAsync() { // Arrange using TempDirectory tempDirectory = new(); string sessionId = Guid.NewGuid().ToString("N"); CheckpointInfo parentCheckpoint; CheckpointInfo childCheckpoint; CheckpointInfo unrelatedCheckpoint; string childFileName; using (FileSystemJsonCheckpointStore store = new(tempDirectory)) { parentCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData); childCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData, parentCheckpoint); unrelatedCheckpoint = await store.CreateCheckpointAsync(sessionId, TestData); childFileName = store.GetFileNameForCheckpoint(sessionId, childCheckpoint); } string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl"); string legacyEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(childCheckpoint, childFileName)); File.WriteAllText(indexPath, legacyEntry + Environment.NewLine); // Act using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory); CheckpointInfo[] childIndex = (await reopenedStore.RetrieveIndexAsync(sessionId, unrelatedCheckpoint)).ToArray(); // Assert childIndex.Should().ContainSingle().Which.Should().Be(childCheckpoint); } }