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
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ roots and never to user-supplied filesystem paths.
- Compose mounts the configured source path read-only
- API and indexer receive the root path from configuration only
- Indexed document paths are normalized relative paths
- The current foundation establishes the boundary contract now, while deeper
hardening and verification remain follow-on work rather than implied current
guarantees
- The indexer canonicalizes discovered entry paths before ingestion and skips
any entry that cannot be proven to remain inside the configured root
- The indexer does not follow symlinks or other reparse-point entries during
ingestion, so filesystem indirection cannot silently widen the source scope
15 changes: 13 additions & 2 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,20 @@ Verify source-boundary configuration:
- confirm the deployment does not rely on client requests to supply filesystem
paths or widen ingestion scope
- confirm the configured directory does not depend on symlink or reparse-point
traversal to reach required content until deeper boundary hardening work lands
traversal to reach required content; Strata now skips those entries during
ingestion instead of following them
- confirm the running stack does not need broader host filesystem access than
the declared source root

Verify runtime boundary enforcement:

- place or identify a symlink or reparse-point entry under `STRATA_SOURCES`
that targets content outside the intended source root
- run an indexing job and confirm the indexer logs a warning that the entry was
skipped instead of traversed
- confirm only markdown files that remain provably inside the configured source
boundary are ingested

Smoke-test search:

```powershell
Expand Down Expand Up @@ -228,7 +238,8 @@ Current logging intent:
- retrieval logs include request context such as trace id, request path, method,
request outcome, and safe request metadata like query length or result count
- ingestion logs include worker id, job id, configured source root, lifecycle
events, sync counts, and failure details
events, sync counts, failure details, and boundary-skip warnings when unsafe
filesystem entries are encountered
- document content and raw search query text should not be logged as part of
routine retrieval diagnostics

Expand Down
159 changes: 146 additions & 13 deletions src/Codex.Indexer/Indexing/MarkdownDocumentScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,18 @@ public sealed record ScannedMarkdownDocument(
string Content,
string Checksum);

public sealed class MarkdownDocumentScanner
public sealed class MarkdownDocumentScanner(ILogger<MarkdownDocumentScanner> logger)
{
public async Task<IReadOnlyList<ScannedMarkdownDocument>> ScanAsync(
string docsRoot,
CancellationToken cancellationToken)
{
var normalizedDocsRoot = NormalizeRootPath(docsRoot);

// Stable ordering keeps repeated scans deterministic.
var markdownFiles = Directory
.EnumerateFiles(docsRoot, "*", SearchOption.AllDirectories)
.Where(static filePath =>
string.Equals(
Path.GetExtension(filePath),
".md",
StringComparison.OrdinalIgnoreCase))
.Select(filePath => new
{
FullPath = filePath,
RelativePath = NormalizeRelativePath(Path.GetRelativePath(docsRoot, filePath))
})
var markdownFiles = EnumerateMarkdownFilesWithinBoundary(
normalizedDocsRoot,
cancellationToken)
.OrderBy(file => file.RelativePath, StringComparer.Ordinal)
.ToArray();

Expand All @@ -54,6 +47,63 @@ public async Task<IReadOnlyList<ScannedMarkdownDocument>> ScanAsync(
return scannedDocuments;
}

private IEnumerable<(string FullPath, string RelativePath)> EnumerateMarkdownFilesWithinBoundary(
string docsRoot,
CancellationToken cancellationToken)
{
var pendingDirectories = new Stack<string>();
pendingDirectories.Push(docsRoot);

while (pendingDirectories.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();

var currentDirectory = pendingDirectories.Pop();
IEnumerable<string> entries;
try
{
entries = Directory.EnumerateFileSystemEntries(currentDirectory);
}
catch (Exception ex) when (
ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(
ex,
"Skipped directory during ingestion because it could not be enumerated (directory_path: {DirectoryPath})",
currentDirectory);
continue;
}

foreach (var entryPath in entries)
{
cancellationToken.ThrowIfCancellationRequested();

if (!TryResolveSafeEntry(docsRoot, entryPath, out var resolvedEntry, out var isDirectory))
{
continue;
}

if (isDirectory)
{
pendingDirectories.Push(resolvedEntry);
continue;
}

if (!string.Equals(
Path.GetExtension(resolvedEntry),
".md",
StringComparison.OrdinalIgnoreCase))
{
continue;
}

yield return (
resolvedEntry,
NormalizeRelativePath(Path.GetRelativePath(docsRoot, resolvedEntry)));
}
}
}

private static string ExtractTitle(string content, string fullPath)
{
// Prefer first heading for title; fall back to file name when no heading exists.
Expand Down Expand Up @@ -87,6 +137,89 @@ private static string ComputeChecksum(byte[] contentBytes)
return Convert.ToHexString(hash).ToLowerInvariant();
}

private bool TryResolveSafeEntry(
string docsRoot,
string entryPath,
out string resolvedEntry,
out bool isDirectory)
{
resolvedEntry = string.Empty;
isDirectory = false;

string normalizedEntryPath;
try
{
normalizedEntryPath = Path.GetFullPath(entryPath);
}
catch (Exception ex) when (
ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning(
ex,
"Skipped filesystem entry because its path could not be normalized safely (entry_path: {EntryPath})",
entryPath);
return false;
}

if (!IsWithinBoundary(docsRoot, normalizedEntryPath))
{
logger.LogWarning(
"Skipped filesystem entry because it resolved outside the configured source boundary (entry_path: {EntryPath}, docs_root: {DocsRoot})",
normalizedEntryPath,
docsRoot);
return false;
}

FileAttributes attributes;
try
{
attributes = File.GetAttributes(normalizedEntryPath);
}
catch (Exception ex) when (
ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(
ex,
"Skipped filesystem entry because its attributes could not be read safely (entry_path: {EntryPath})",
normalizedEntryPath);
return false;
}

if ((attributes & FileAttributes.ReparsePoint) != 0)
{
logger.LogWarning(
"Skipped filesystem entry because Strata does not follow reparse points during ingestion (entry_path: {EntryPath})",
normalizedEntryPath);
return false;
}

resolvedEntry = normalizedEntryPath;
isDirectory = (attributes & FileAttributes.Directory) != 0;
return true;
}

private static string NormalizeRootPath(string docsRoot)
{
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(docsRoot));
}

private static bool IsWithinBoundary(string docsRoot, string candidatePath)
{
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

if (string.Equals(candidatePath, docsRoot, comparison))
{
return true;
}

var docsRootWithSeparator = Path.EndsInDirectorySeparator(docsRoot)
? docsRoot
: $"{docsRoot}{Path.DirectorySeparatorChar}";
return candidatePath.StartsWith(docsRootWithSeparator, comparison);
}

private static string NormalizeRelativePath(string relativePath)
{
// Normalize path separators so DB paths are consistent across OSes.
Expand Down
Loading