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 EnumerateTaskIds() { if (!Directory.Exists(_root)) return Array.Empty(); 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 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) { } } }