// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// /// A file-system-backed implementation of that stores files on disk /// under a configurable root directory. /// /// /// /// All paths passed to this store are resolved relative to the root directory provided /// at construction time. Lexical path traversal attempts (for example, via .. segments /// or absolute paths) are rejected with an . /// /// /// The root directory is created automatically if it does not already exist. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FileSystemAgentFileStore : AgentFileStore { /// /// The canonical full path of the root directory, always ending with a directory separator. /// private readonly string _rootPath; /// /// Initializes a new instance of the class. /// /// /// The root directory under which all files are stored. Created if it does not exist. /// public FileSystemAgentFileStore(string rootDirectory) { _ = Throw.IfNullOrWhitespace(rootDirectory); // Canonicalize the root and ensure it ends with a separator for prefix comparison. string fullRoot = Path.GetFullPath(rootDirectory); if (!fullRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) && !fullRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { fullRoot += Path.DirectorySeparatorChar; } this._rootPath = fullRoot; Directory.CreateDirectory(fullRoot); } /// public override async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default) { string fullPath = this.ResolveSafePath(path); // Ensure the parent directory exists. string? parentDir = Path.GetDirectoryName(fullPath); if (parentDir is not null) { Directory.CreateDirectory(parentDir); } #if NET8_0_OR_GREATER await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false); #else using var writer = new StreamWriter(fullPath, false, Encoding.UTF8); await writer.WriteAsync(content).ConfigureAwait(false); #endif } /// public override async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { string fullPath = this.ResolveSafePath(path); if (!File.Exists(fullPath)) { return null; } #if NET8_0_OR_GREATER return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); #else using var reader = new StreamReader(fullPath, Encoding.UTF8); return await reader.ReadToEndAsync().ConfigureAwait(false); #endif } /// public override Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) { string fullPath = this.ResolveSafePath(path); if (!File.Exists(fullPath)) { return Task.FromResult(false); } File.Delete(fullPath); return Task.FromResult(true); } /// public override Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default) { string fullDir = this.ResolveSafeDirectoryPath(directory); if (!Directory.Exists(fullDir)) { return Task.FromResult>([]); } var files = Directory.GetFiles(fullDir) .Where(f => (File.GetAttributes(f) & FileAttributes.ReparsePoint) == 0) .Select(Path.GetFileName) .Where(name => name is not null) .ToList(); return Task.FromResult>(files!); } /// public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) { string fullPath = this.ResolveSafePath(path); return Task.FromResult(File.Exists(fullPath)); } /// public override async Task> SearchFilesAsync( string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) { string fullDir = this.ResolveSafeDirectoryPath(directory); if (!Directory.Exists(fullDir)) { return []; } // 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 ? StorePaths.CreateGlobMatcher(filePattern) : null; var results = new List(); foreach (string filePath in Directory.GetFiles(fullDir)) { // Skip files that are symlinks/reparse points to prevent reading outside the root. if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0) { continue; } string? fileName = Path.GetFileName(filePath); if (fileName is null) { continue; } // Apply the optional glob filter on the file name. if (!StorePaths.MatchesGlob(fileName, matcher)) { continue; } // Read file content. #if NET8_0_OR_GREATER string fileContent = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); #else string fileContent; using (var reader = new StreamReader(filePath, Encoding.UTF8)) { fileContent = await reader.ReadToEndAsync().ConfigureAwait(false); } #endif // Search each line for regex matches, tracking line numbers and building a snippet. string[] lines = fileContent.Split('\n'); var matchingLines = new List(); string? firstSnippet = null; int lineStartOffset = 0; for (int i = 0; i < lines.Length; i++) { Match match = regex.Match(lines[i]); if (match.Success) { matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') }); // Build a context snippet around the first match (±50 chars). if (firstSnippet is null) { int charIndex = lineStartOffset + match.Index; int snippetStart = Math.Max(0, charIndex - 50); int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50); firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart); } } // Advance the offset past this line (including the '\n' separator). lineStartOffset += lines[i].Length + 1; } if (matchingLines.Count > 0) { results.Add(new FileSearchResult { FileName = fileName, Snippet = firstSnippet!, MatchingLines = matchingLines, }); } } return results; } /// public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) { string fullPath = this.ResolveSafeDirectoryPath(path); Directory.CreateDirectory(fullPath); return Task.CompletedTask; } /// /// Resolves a relative file path to a safe absolute path under the root directory. /// Rejects paths that would escape the root via traversal, rooted paths, or symbolic links. /// private string ResolveSafePath(string relativePath) { // Normalize and validate the relative path (rejects rooted, traversal, etc.). string normalized = StorePaths.NormalizeRelativePath(relativePath); // 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)) { throw new ArgumentException( $"Invalid path: '{relativePath}'. The resolved path escapes the root directory.", nameof(relativePath)); } // Reject symlinks/reparse points in any path segment to prevent escaping the root. ThrowIfContainsSymlink(fullPath, this._rootPath); return fullPath; } /// /// Checks each path segment between the trusted root and the resolved path for symbolic links /// or reparse points. Throws if any segment is a symlink. /// Stops checking at the first segment that does not exist on disk (for write scenarios). /// Uses directly so that dangling symlinks (whose targets /// do not exist) are still detected via their flag. /// private static void ThrowIfContainsSymlink(string fullPath, string rootPath) { string rootTrimmed = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string relative = fullPath.Substring(rootTrimmed.Length); string[] segments = relative.Split( [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); string current = rootTrimmed; foreach (string segment in segments) { current = Path.Combine(current, segment); FileAttributes attributes; try { attributes = File.GetAttributes(current); } catch (FileNotFoundException) { // Segment does not exist on disk (write scenario); stop checking. break; } catch (DirectoryNotFoundException) { break; } if ((attributes & FileAttributes.ReparsePoint) != 0) { throw new ArgumentException( "Invalid path: the resolved path contains a symbolic link or reparse point."); } } } /// /// Resolves a relative directory path to a safe absolute path under the root directory. /// An empty string resolves to the root directory itself. /// private string ResolveSafeDirectoryPath(string relativeDirectory) { if (string.IsNullOrEmpty(relativeDirectory)) { return this._rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } return this.ResolveSafePath(relativeDirectory); } }