Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,39 +90,4 @@ public abstract class AgentFileStore
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a <see cref="Matcher"/> for the specified glob pattern. Use the returned instance
/// to test multiple file names without allocating a new matcher for each one.
/// </summary>
/// <param name="filePattern">
/// The glob pattern to match against (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// </param>
/// <returns>A <see cref="Matcher"/> configured with the specified pattern.</returns>
protected static Matcher CreateGlobMatcher(string filePattern)
{
var matcher = new Matcher(System.StringComparison.OrdinalIgnoreCase);
matcher.AddInclude(filePattern);
return matcher;
}

/// <summary>
/// Determines whether a file name matches a pre-built glob <see cref="Matcher"/>.
/// </summary>
/// <param name="fileName">The file name to test (not a full path — just the name).</param>
/// <param name="matcher">
/// A pre-built <see cref="Matcher"/> to test against.
/// When <see langword="null"/>, this method returns <see langword="true"/> for any file name.
/// </param>
/// <returns><see langword="true"/> if the file name matches the pattern or if the matcher is <see langword="null"/>; otherwise, <see langword="false"/>.</returns>
protected static bool MatchesGlob(string fileName, Matcher? matcher)
{
if (matcher is null)
{
return true;
}

PatternMatchingResult result = matcher.Match(fileName);
return result.HasMatches;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -264,26 +263,12 @@ private static string GetDescriptionFileName(string fileName)

private static string ResolvePath(string workingFolder, string fileName)
{
// Prevent path traversal by rejecting rooted paths and '.'/'..' segments.
string normalized = fileName.Replace('\\', '/');
// Validate and normalize the file name (rejects rooted, traversal, empty, etc.).
// Only fileName needs validation — workingFolder is developer-provided and trusted.
string normalizedFileName = StorePaths.NormalizeRelativePath(fileName);

if (Path.IsPathRooted(fileName) ||
fileName.StartsWith("/", StringComparison.Ordinal) ||
fileName.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException($"Invalid file name: '{fileName}'. File names must be relative and must not start with '/', '\\', or a drive root.", nameof(fileName));
}

foreach (string segment in normalized.Split('/'))
{
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid file name: '{fileName}'. File names must not contain '.' or '..' segments.", nameof(fileName));
}
}

return CombinePaths(workingFolder, fileName);
string normalizedWorkingFolder = workingFolder.Replace('\\', '/');
return CombinePaths(normalizedWorkingFolder, normalizedFileName);
}

private static string CombinePaths(string basePath, string relativePath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ public override async Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(

// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null;
Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
var results = new List<FileSearchResult>();

foreach (string filePath in Directory.GetFiles(fullDir))
Expand All @@ -164,7 +164,7 @@ public override async Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(
}

// Apply the optional glob filter on the file name.
if (!MatchesGlob(fileName, matcher))
if (!StorePaths.MatchesGlob(fileName, matcher))
{
continue;
}
Expand Down Expand Up @@ -235,12 +235,12 @@ public override Task CreateDirectoryAsync(string path, CancellationToken cancell
/// </summary>
private string ResolveSafePath(string relativePath)
{
ValidateRelativePath(relativePath);
// Normalize and validate the relative path (rejects rooted, traversal, etc.).
string normalized = StorePaths.NormalizeRelativePath(relativePath);

// Normalize separators before combining to prevent backslashes from becoming
// literal filename characters on Unix.
string normalized = relativePath.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar);
string combined = Path.Combine(this._rootPath, normalized);
// Convert to OS-native separators before combining.
string nativePath = normalized.Replace('/', Path.DirectorySeparatorChar);
string combined = Path.Combine(this._rootPath, nativePath);
string fullPath = Path.GetFullPath(combined);

if (!fullPath.StartsWith(this._rootPath, StringComparison.Ordinal))
Expand All @@ -266,40 +266,4 @@ private string ResolveSafeDirectoryPath(string relativeDirectory)

return this.ResolveSafePath(relativeDirectory);
}

/// <summary>
/// Validates that a relative path does not contain rooted paths or traversal segments.
/// </summary>
private static void ValidateRelativePath(string path)
{
string normalized = path.Replace('\\', '/');

if (Path.IsPathRooted(path) ||
path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
nameof(path));
}

foreach (string segment in normalized.Split('/'))
{
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
nameof(path));
}
}

if (normalized.StartsWith("/", StringComparison.Ordinal) ||
normalized.EndsWith("/", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not start or end with a directory separator.",
nameof(path));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
Expand All @@ -29,30 +28,30 @@ public sealed class InMemoryAgentFileStore : AgentFileStore
/// <inheritdoc />
public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
path = StorePaths.NormalizeRelativePath(path);
this._files[path] = content;
return Task.CompletedTask;
}

/// <inheritdoc />
public override Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
path = StorePaths.NormalizeRelativePath(path);
this._files.TryGetValue(path, out string? content);
return Task.FromResult(content);
}

/// <inheritdoc />
public override Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
path = StorePaths.NormalizeRelativePath(path);
return Task.FromResult(this._files.TryRemove(path, out _));
}

/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
{
string prefix = NormalizePath(directory);
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
Expand All @@ -70,23 +69,23 @@ public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, Can
/// <inheritdoc />
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
{
path = NormalizePath(path);
path = StorePaths.NormalizeRelativePath(path);
return Task.FromResult(this._files.ContainsKey(path));
}

/// <inheritdoc />
public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default)
{
// Normalize the directory prefix for path matching.
string prefix = NormalizePath(directory);
string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true);
if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal))
{
prefix += "/";
}

// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null;
Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null;
var results = new List<FileSearchResult>();

foreach (var kvp in this._files)
Expand All @@ -105,7 +104,7 @@ public override Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(string di
}

// Apply the optional glob filter on the file name.
if (!MatchesGlob(relativeName, matcher))
if (!StorePaths.MatchesGlob(relativeName, matcher))
{
continue;
}
Expand Down Expand Up @@ -158,31 +157,4 @@ public override Task CreateDirectoryAsync(string path, CancellationToken cancell
// No-op: directories are implicit from file paths in the in-memory store.
return Task.CompletedTask;
}

private static string NormalizePath(string path)
{
string normalized = path.Replace('\\', '/').Trim('/');

if (Path.IsPathRooted(path) ||
path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
nameof(path));
}

foreach (string segment in normalized.Split('/'))
{
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
nameof(path));
}
}

return normalized;
}
}
109 changes: 109 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.FileSystemGlobbing;

namespace Microsoft.Agents.AI;

/// <summary>
/// Internal helper for normalizing and validating relative store paths and matching glob patterns.
/// Shared across <see cref="AgentFileStore"/> implementations and <see cref="FileMemoryProvider"/>.
/// </summary>
internal static class StorePaths
{
/// <summary>
/// Normalizes a relative path by replacing backslashes with forward slashes, trimming leading
/// and trailing separators, and collapsing consecutive separators. Also validates that the path
/// does not contain rooted paths, drive roots, or <c>.</c>/<c>..</c> traversal segments.
/// </summary>
/// <param name="path">The relative path to normalize.</param>
/// <param name="isDirectory">
/// When <see langword="true"/>, the path represents a directory and an empty result (meaning root) is allowed.
/// When <see langword="false"/> (default), the path represents a file and an empty result is rejected.
/// </param>
/// <returns>The normalized forward-slash path.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="path"/> is rooted, starts with a drive letter, contains
/// <c>.</c> or <c>..</c> segments, or is empty when <paramref name="isDirectory"/> is <see langword="false"/>.
/// </exception>
internal static string NormalizeRelativePath(string path, bool isDirectory = false)
{
string normalized = path.Replace('\\', '/').Trim('/');

if (Path.IsPathRooted(path) ||
path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
nameof(path));
}

// Split, validate segments, and filter out empty segments to collapse consecutive separators.
string[] segments = normalized.Split('/');
var cleanSegments = new List<string>(segments.Length);
foreach (string segment in segments)
{
if (segment.Length == 0)
{
continue;
}

if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
nameof(path));
}

cleanSegments.Add(segment);
}

string result = string.Join("/", cleanSegments);

if (!isDirectory && result.Length == 0)
{
throw new ArgumentException("A file path must not be empty.", nameof(path));
}

return result;
}

/// <summary>
/// Creates a <see cref="Matcher"/> for the specified glob pattern. Use the returned instance
/// to test multiple file names without allocating a new matcher for each one.
/// </summary>
/// <param name="filePattern">
/// The glob pattern to match against (e.g., <c>"*.md"</c>, <c>"research*"</c>).
/// </param>
/// <returns>A <see cref="Matcher"/> configured with the specified pattern.</returns>
internal static Matcher CreateGlobMatcher(string filePattern)
{
var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
matcher.AddInclude(filePattern);
return matcher;
}

/// <summary>
/// Determines whether a file name matches a pre-built glob <see cref="Matcher"/>.
/// </summary>
/// <param name="fileName">The file name to test (not a full path — just the name).</param>
/// <param name="matcher">
/// A pre-built <see cref="Matcher"/> to test against.
/// When <see langword="null"/>, this method returns <see langword="true"/> for any file name.
/// </param>
/// <returns><see langword="true"/> if the file name matches the pattern or if the matcher is <see langword="null"/>; otherwise, <see langword="false"/>.</returns>
internal static bool MatchesGlob(string fileName, Matcher? matcher)
{
if (matcher is null)
{
return true;
}

PatternMatchingResult result = matcher.Match(fileName);
return result.HasMatches;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,14 @@ public async Task WriteFileAsync_DoubleDotsInFileName_AllowedAsync()
}

[Fact]
public async Task WriteFileAsync_TrailingSlash_ThrowsAsync()
public async Task WriteFileAsync_TrailingSlash_NormalizesAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("subdir/", "content"));
// Act — trailing slash is trimmed during normalization.
await this._store.WriteFileAsync("subdir/", "content");

// Assert — the file is accessible via the normalized name.
string? result = await this._store.ReadFileAsync("subdir");
Assert.Equal("content", result);
}

#endregion
Expand Down
Loading
Loading