diff --git a/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs
index 8a82236b..5184a541 100644
--- a/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs
+++ b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs
@@ -49,7 +49,7 @@ public async Task FindEntriesAsync_And_ListEntriesAsync_Should_Report_The_Worksp
await Assert.That(listed.Status).IsEqualTo(200);
await Assert.That(listed.IsError).IsFalse();
await Assert.That(listed.Location.Directory).IsEqualTo(found.Location.Directory);
- await Assert.That(workspace.HasTextFile(listed.Location.Directory, RootFile, owner)).IsTrue();
+ await Assert.That(workspace.ReadTextFile(listed.Location.Directory, RootFile)).IsEqualTo(owner);
var rootFile = listed.Entries.Single(item => NormalizeSeparators(item.Path) == RootFile);
await Assert.That(rootFile.Type).IsEqualTo(FileSystemEntryType.File);
var childDirectory = listed.Entries.Single(
@@ -140,7 +140,7 @@ public async Task WriteFileAsync_Should_Write_Bytes_That_ReadFileAsync_Reads_Bac
var read = await client.FileSystem.ReadFileAsync(new FsReadRequest { Path = WriteTarget }, cancellationToken: cancellationToken);
await Assert.That(read.Content.ToArray()).IsEquivalentTo(bytes, CollectionOrdering.Matching);
- await Assert.That(workspace.HasBytes(WriteTarget, bytes)).IsTrue();
+ await Assert.That(workspace.ReadBytes(WriteTarget)).IsEquivalentTo(bytes, CollectionOrdering.Matching);
Console.WriteLine(
"filesystem-live: write-status=" + Number(written.Status) +
diff --git a/tests/OpenCode.Sdk.Tests/Harness/TestWorkspace.cs b/tests/OpenCode.Sdk.Tests/Harness/TestWorkspace.cs
index f7a8da46..1649663d 100644
--- a/tests/OpenCode.Sdk.Tests/Harness/TestWorkspace.cs
+++ b/tests/OpenCode.Sdk.Tests/Harness/TestWorkspace.cs
@@ -76,6 +76,23 @@ public bool HasTextFile(string directory, string relativePath, string expectedCo
}
}
+ /// Reads the bytes of a file below the workspace, refusing paths that escape it.
+ public byte[] ReadBytes(string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+
+ return _fileSystem.File.ReadAllBytes(ResolvePathBelow(Path, relativePath));
+ }
+
+ /// Reads the contents below a reported directory, refusing paths that escape it.
+ public string ReadTextFile(string directory, string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+
+ return _fileSystem.File.ReadAllText(ResolvePathBelow(directory, relativePath));
+ }
+
private string ResolvePathBelow(string root, string relativePath)
{
if (_fileSystem.Path.IsPathRooted(relativePath))
diff --git a/tests/OpenCode.Sdk.Tests/Harness/TestWorkspaceTests.cs b/tests/OpenCode.Sdk.Tests/Harness/TestWorkspaceTests.cs
index 26686cd1..4ecabc52 100644
--- a/tests/OpenCode.Sdk.Tests/Harness/TestWorkspaceTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Harness/TestWorkspaceTests.cs
@@ -1,4 +1,7 @@
+using System.Globalization;
using Testably.Abstractions.Testing;
+using Testably.Abstractions.Testing.Initializer;
+using TUnit.Assertions.Enums;
namespace OpenCode.Sdk.Tests.Harness;
@@ -71,4 +74,85 @@ public async Task HasTextFile_Should_Distinguish_The_Owned_Marker_From_Wrong_Or_
await Assert.That(workspace.HasTextFile(workspace.Path, marker, "another-owner")).IsFalse();
await Assert.That(workspace.HasTextFile(workspace.Path, "missing-marker.txt", owner)).IsFalse();
}
+
+ [Test]
+ public async Task ReadBytes_Should_Return_The_Actual_Nested_File_Contents()
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+ _ = workspace.WriteTextFile("nested/bytes.txt", "cab");
+
+ await Assert.That(workspace.ReadBytes("nested/bytes.txt"))
+ .IsEquivalentTo(new byte[] { 99, 97, 98 }, CollectionOrdering.Matching);
+ }
+
+ [Test]
+ public async Task ReadTextFile_Should_Return_The_Actual_Content_From_A_Reported_Directory()
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+ var reported = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "reported-project");
+ _ = fileSystem.Initialize().With(new FileDescription(
+ fileSystem.Path.Combine(reported, "nested", "owner.txt"), "another-owner"));
+
+ await Assert.That(workspace.ReadTextFile(reported, "nested/owner.txt")).IsEqualTo("another-owner");
+ }
+
+ [Test]
+ public async Task Reads_Should_Return_The_Wrong_Content_Rather_Than_An_Ownership_Verdict()
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+ _ = workspace.WriteTextFile("marker.txt", "owned");
+ _ = workspace.WriteTextFile("marker.txt", "wrong");
+
+ await Assert.That(workspace.ReadTextFile(workspace.Path, "marker.txt")).IsEqualTo("wrong");
+ await Assert.That(workspace.ReadBytes("marker.txt"))
+ .IsEquivalentTo(new byte[] { 119, 114, 111, 110, 103 }, CollectionOrdering.Matching);
+ }
+
+ [Test]
+ public async Task Reads_Should_Propagate_A_Missing_File()
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+
+ _ = await Assert.That(() => workspace.ReadBytes("missing.txt")).ThrowsExactly();
+ _ = await Assert.That(() => workspace.ReadTextFile(workspace.Path, "missing.txt")).ThrowsExactly();
+ }
+
+ [Test]
+ [Arguments("")]
+ [Arguments(" ")]
+ public async Task ReadTextFile_Should_Reject_A_Blank_Directory(string directory)
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+
+ var exception = await Assert.That(() => workspace.ReadTextFile(directory, "marker.txt")).ThrowsExactly();
+
+ await Assert.That(exception!.ParamName).IsEqualTo("directory");
+ }
+
+ [Test]
+ [Arguments("")]
+ [Arguments(" ")]
+ [Arguments("/rooted-outside.txt")]
+ [Arguments("../traversal-outside.txt")]
+ [Arguments("../{0}-sibling/outside.txt")]
+ public async Task Reads_Should_Reject_A_Blank_Or_Escaping_Path(string pathTemplate)
+ {
+ var fileSystem = new MockFileSystem();
+ using var workspace = new TestWorkspace(fileSystem, fileSystem.Path.GetTempPath());
+ var relativePath = string.Format(CultureInfo.InvariantCulture, pathTemplate, fileSystem.Path.GetFileName(workspace.Path));
+ _ = fileSystem.Initialize().With(
+ new FileDescription(fileSystem.Path.GetFullPath("/rooted-outside.txt"), "rooted content"),
+ new FileDescription(fileSystem.Path.GetFullPath(fileSystem.Path.Combine(workspace.Path, "../traversal-outside.txt")), "traversal content"),
+ new FileDescription(workspace.Path + "-sibling/outside.txt", "sibling content"));
+
+ var bytes = await Assert.That(() => workspace.ReadBytes(relativePath)).ThrowsExactly();
+ await Assert.That(bytes!.ParamName).IsEqualTo("relativePath");
+ var text = await Assert.That(() => workspace.ReadTextFile(workspace.Path, relativePath)).ThrowsExactly();
+ await Assert.That(text!.ParamName).IsEqualTo("relativePath");
+ }
}
diff --git a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs
index 959c57b2..103facd4 100644
--- a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs
@@ -61,7 +61,7 @@ public async Task GetLocationAsync_And_ListProjectsAsync_Should_Report_The_Works
await Assert.That(current.IsError).IsFalse();
await Assert.That(string.IsNullOrWhiteSpace(current.ResolvedLocation.Project.Id)).IsFalse();
await Assert.That(current.ResolvedLocation.Project.Canonical).IsEqualTo(current.ResolvedLocation.Project.Directory);
- await Assert.That(workspace.HasTextFile(current.ResolvedLocation.Project.Directory, OwnerFile, owner)).IsTrue();
+ await Assert.That(workspace.ReadTextFile(current.ResolvedLocation.Project.Directory, OwnerFile)).IsEqualTo(owner);
var listed = await client.Projects.ListProjectsAsync(cancellationToken: cancellationToken);
@@ -69,7 +69,7 @@ public async Task GetLocationAsync_And_ListProjectsAsync_Should_Report_The_Works
await Assert.That(listed.IsError).IsFalse();
var project = listed.Projects.Single(item => item.Id == current.ResolvedLocation.Project.Id);
await Assert.That(project.Canonical).IsEqualTo(current.ResolvedLocation.Project.Canonical);
- await Assert.That(workspace.HasTextFile(project.Canonical, OwnerFile, owner)).IsTrue();
+ await Assert.That(workspace.ReadTextFile(project.Canonical, OwnerFile)).IsEqualTo(owner);
Console.WriteLine(
"projects-live: current=" + Number(current.Status) +