Files
ClaudeDo/src/ClaudeDo.Data/AttachmentStore.cs
T
Mika Kuns 6a0c0f59a5 feat(attachments): inject reference files into the run + clean up files on delete
TaskRunner appends attached files (absolute paths) to the run prompt as the
read-only Reference files section. Task and list deletes now remove the
on-disk attachment dir eagerly, and a startup AttachmentOrphanRecovery sweep
drops any attachments/<taskId>/ whose task no longer exists (covers list
cascade and planning-discard paths).
2026-06-26 16:11:48 +02:00

86 lines
3.2 KiB
C#

namespace ClaudeDo.Data;
public sealed class AttachmentStore
{
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
private readonly string _root;
public AttachmentStore(string? root = null)
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
public string Root => _root;
public IReadOnlyList<string> EnumerateTaskIds()
{
if (!Directory.Exists(_root)) return Array.Empty<string>();
return Directory.GetDirectories(_root)
.Select(Path.GetFileName)
.Where(n => n is not null)
.Select(n => n!)
.ToList();
}
public string TaskDir(string taskId)
=> Path.Combine(_root, taskId);
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
{
if (Path.GetFileName(fileName) != fileName)
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
var dir = TaskDir(taskId);
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
// Containment guard: resolved path must stay inside TaskDir
var resolvedDir = Path.GetFullPath(dir);
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
Directory.CreateDirectory(dir);
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await content.ReadAsync(buffer, ct)) > 0)
{
total += read;
if (total > MaxBytes)
{
fs.Close();
try { File.Delete(resolvedPath); } catch { }
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
}
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
}
return total;
}
public void DeleteFile(string taskId, string fileName)
{
if (Path.GetFileName(fileName) != fileName)
return; // traversal attempt — ignore silently
var dir = TaskDir(taskId);
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
var resolvedDir = Path.GetFullPath(dir);
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
return; // containment violation — ignore silently
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
}
public void DeleteTaskDir(string taskId)
{
var dir = TaskDir(taskId);
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
}
}