Repository navigation
feat(attachments): MountView-based attachment landing crate (#4644) - #4668
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
abbyshekit
left a comment
There was a problem hiding this comment.
Code review — feat(attachments): MountView-based attachment landing crate (#4644)
Multi-agent review (security · bugs · performance · tests · conventions) at d1f9b109. Diff-only mode.
Review of a draft/WIP PR (review requested) — early feedback; some items may be addressed in later commits of the stack.
4 findings — 2 High, 1 Medium, 1 Nit. Posted as a comment (advisory). Confidence ≥ 50, deduplicated across reviewers.
| Sev | Conf | Reviewer | Location | Finding |
|---|---|---|---|---|
| High | 80% | bugs | landing.rs:98 |
Two attachments in one message with the same filename collide and silently overwrite (index never enters the path) |
| High | 80% | tests | landing.rs:365 (in body) |
No test for attachment path collision / silent overwrite (last-write-wins) |
| Medium | 72% | performance | landing.rs:121 |
Attachment bytes landed with no size cap (full in-memory materialization + unbounded disk growth) |
| Nit | 62% | conventions | Cargo.toml:1 |
New crate enforces neither workspace lints nor the inline unreachable_pub warn its dependencies use |
Findings without an inline anchor (cited lines fall outside the diff hunks)
[High · 80%] No test for attachment path collision / silent overwrite (last-write-wins)
crates/ironclaw_attachments/src/landing.rs:365-383 — tests reviewer
land_attachment writes via ScopedFilesystem::write_bytes, which calls put(.., CasExpectation::Any) (verified in crates/ironclaw_filesystem/src/scoped.rs:365-374 and :101-111) — an unconditional last-write-wins overwrite, no CAS, no dedup. attachment_scoped_path derives the path from {project_alias}/attachments/{date}/{message_id}-{filename}; the index field only disambiguates the SYNTHESIZED fallback name (fallback_filename, used when filename is None), NOT a caller-provided filename. So two attachments in the same message with the same provided filename and date resolve to the identical ScopedPath, and the second land_attachment call silently overwrites the first attachment's bytes — data loss. The PR's tests cover the happy-path build, the synthesized-name path, one traversal string, a read-write round trip, and the read-only fail-closed path, but none assert what happens on a duplicate path. The focus for this PR explicitly calls out 'path COLLISION/overwrite when two attachments share message_id/filename' and 'negative tests for ... collision'. This is happy-path-only coverage of a write routine whose collision semantics are load-bearing.
Fix: Add tests::landing::two_attachments_with_same_message_id_and_filename_collide covering the collision case: land_attachment twice over one InMemoryBackend with identical message_id/date/filename but different bytes, then read back via get and assert which write wins (documents the last-write-wins overwrite), OR — if collision is meant to be prevented — assert the second landing produces a distinct ScopedPath (e.g. index-suffixed) so no overwrite occurs.
Generated by near-ai-code-review (5 parallel reviewer agents + intent analysis). Diff-only; confidence ≥ 50; ≤ 15 inline comments.
| ) -> Result<ScopedPath, AttachmentLandingError> { | ||
| let date = sanitize_attachment_segment(date); | ||
| let message_id = sanitize_attachment_segment(landing.message_id); | ||
| let filename = match landing.filename { |
There was a problem hiding this comment.
[High · 80% confidence] Two attachments in one message with the same filename collide and silently overwrite (index never enters the path)
bugs (also: performance, tests) reviewer
attachment_scoped_path builds the storage key as {project_alias}/attachments/{date}/{message_id}-{filename}. The index field — documented at line 41-43 as "Zero-based index of this attachment within its message. Disambiguates same-named files" — is ONLY consulted in the None branch (line 100, via fallback_filename). When a filename is present (line 99), index is dropped entirely, so the path contains no per-attachment uniqueness.
Many channels deliver multiple attachments in a single message that share a filename (e.g. two image.png, or a client that names every photo image.jpg). Both land at the identical ScopedPath. land_attachment then calls filesystem.write_bytes(...), which is put(.., CasExpectation::Any) (crates/ironclaw_filesystem/src/scoped.rs:364-373) — an unconditional overwrite. The second attachment silently clobbers the first. The first attachment's ScopedPath was already returned to the caller and is recorded as that attachment's storage key, so it now resolves to the WRONG bytes when the agent reads it back. Since ProductAttachmentDescriptor is bytes-free and the model only sees attachments via read-back from the project FS, the model receives attachment B's bytes for attachment A — a lost-write / wrong-content invariant break, not just a cosmetic clash.
Sanitization widens the collision surface: distinct raw filenames map to one segment (a/b.png and a_b.png both → a_b.png; résumé.txt and r sum .txt both → r_sum_.txt), so even differently-named attachments in the same message+date collide and overwrite. The unconditional-index fix below resolves every same-message variant because each attachment in a message carries a distinct index.
Fix: Always include index in the path segment so each attachment in a message is unique regardless of (possibly colliding) filenames. E.g. build the leaf as {message_id}-{index}-{filename} in both branches, or fold index into fallback_filename and a parallel prefix for the Some branch. Add negative tests: two AttachmentLanding with the same message_id+filename but different index must produce distinct ScopedPaths; and a land-then-land round-trip must not clobber the first attachment's bytes.
let leaf = match landing.filename {
Some(name) => format!("{}-{}", landing.index, sanitize_attachment_segment(name)),
None => fallback_filename(landing.index, landing.fallback_extension),
};
let full = format!(
"{}/{ATTACHMENTS_DIR}/{date}/{message_id}-{leaf}",
project_alias.trim_end_matches('/')
);There was a problem hiding this comment.
Already addressed on this branch (commit 648f39659, predating the review): attachment_scoped_path now renders index into both branches — the leaf is {message_id}-{index}-{filename} (1-based) unconditionally, so two attachments in one message never share a path even with identical (or identically-sanitized) filenames. Tests same_named_attachments_on_one_message_never_collide (path level) and a new same_named_attachments_land_without_clobbering (land-then-land round-trip asserting both byte sets survive distinctly) cover exactly the lost-write case you described.
| /// by `file_read`/`list_dir` in this and later turns with no extra wiring. | ||
| /// | ||
| /// [`MountPermissions`]: ironclaw_host_api::MountPermissions | ||
| pub async fn land_attachment<F>( |
There was a problem hiding this comment.
[Medium · 72% confidence] Attachment bytes landed with no size cap (full in-memory materialization + unbounded disk growth)
performance (also: tests, security) reviewer
land_attachment takes bytes: Vec<u8> (line 127) — the entire attachment already materialized in memory — and passes it straight to write_bytes (line 133) with no size check anywhere in this crate or on the write path. ScopedFilesystem::write_bytes -> put -> backend put impose no byte bound (verified in scoped.rs / local.rs / in_memory.rs). The sibling read API ships read_bytes_bounded(max_bytes) precisely to avoid materializing oversized content, but there is no write-side equivalent here, so the landing routine is the unbounded-write counterpart. A single large attachment is fully buffered as one Vec<u8> (peak RSS ~= file size, plus a copy at Entry::bytes), and many attachments grow attachments/{date}/ without limit, so this leaf routine is the choke point for both memory and disk exhaustion as soon as a channel adapter feeds it real inbound bytes (per-message hot path).
Fix: Add an explicit max_bytes bound to land_attachment/AttachmentLanding and reject (return an AttachmentLandingError variant) when bytes.len() exceeds it before calling write_bytes, mirroring read_bytes_bounded. Document that callers must enforce the cap before buffering the full Vec<u8> (ideally pass a streaming/bounded reader rather than an owned Vec so oversized inputs are rejected without full materialization). Add an oversize negative test.
There was a problem hiding this comment.
Addressed in 5249ebc66. land_attachment now takes a required max_bytes and rejects over-limit input with a new AttachmentLandingError::TooLarge { size, max } before building the path or calling write_bytes — the write-side counterpart to read_bytes_bounded. Added DEFAULT_MAX_ATTACHMENT_BYTES (25 MiB) as a default callers may use or tighten. New test land_rejects_oversized_attachment_before_writing asserts TooLarge and that nothing was written. On full materialization: the bytes already arrive as an owned Vec<u8> at this boundary, so the doc now explicitly directs callers to enforce the same bound on a streaming reader before buffering; the streaming-reader signature is a follow-up for the channel-adapter track that feeds real inbound bytes.
| @@ -0,0 +1,19 @@ | |||
| [package] | |||
There was a problem hiding this comment.
[Nit · 62% confidence] New crate enforces neither workspace lints nor the inline unreachable_pub warn its dependencies use
conventions reviewer
ironclaw_attachments declares no lint policy at all. The repo has two coexisting conventions for the unreachable_pub lint, and this new crate follows neither. (1) The root Cargo.toml defines [workspace.lints.rust] with unreachable_pub = "warn" and dead_code = "warn", opted into per-crate via [lints] workspace = true; its own comment says this is "scoped to the hook/reborn crates" (e.g. crates/ironclaw_reborn/Cargo.toml:90-91, crates/ironclaw_reborn_composition/Cargo.toml:200-201, crates/ironclaw_hooks/Cargo.toml:50-51). This crate is part of "IronClaw Reborn" (per its own lib.rs doc) yet does not opt in. (2) Both of its direct dependencies instead declare the lint inline at crate root: crates/ironclaw_filesystem/src/lib.rs:16 and crates/ironclaw_host_api/src/lib.rs:32 both carry #![warn(unreachable_pub)] (as does crates/ironclaw_common/src/lib.rs:2). The new crate's lib.rs has no such attribute either. Net: every crate this one directly touches enforces unreachable_pub, but ironclaw_attachments enforces nothing. Low impact in practice — the crate's surface is entirely intentional pub use re-exports, so the lint likely would not fire today — but it is a consistency gap with a clear in-repo precedent, and adopting it guards the surface as the crate grows.
Fix: Add [lints]\nworkspace = true to crates/ironclaw_attachments/Cargo.toml (preferred — matches the reborn-crate convention and also picks up dead_code), or add #![warn(unreachable_pub)] at the top of crates/ironclaw_attachments/src/lib.rs to match its two direct dependencies (ironclaw_filesystem, ironclaw_host_api).
# in crates/ironclaw_attachments/Cargo.toml, after [dependencies]/[dev-dependencies]:
[lints]
workspace = trueThere was a problem hiding this comment.
Addressed (commit ad08a17a4): crates/ironclaw_attachments/Cargo.toml now carries [lints]\nworkspace = true, matching the reborn-crate convention (and picking up dead_code too).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughRepo invariants: none violated (CLAUDE.md/AGENTS.md/.claude/rules). New crate ChangesAttachment Landing Module
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
4a7ac82 to
eb0ebb9
Compare
068a7d0 to
9105e29
Compare
eb0ebb9 to
6c167af
Compare
Track 6 (byte-storage foundation) of #4644. New leaf crate ironclaw_attachments owns the single, channel-agnostic routine that writes inbound attachment bytes into the agent-accessible project filesystem and returns the ScopedPath to record as an attachment's storage key. The write goes THROUGH the project-scoped ScopedFilesystem authority (deps: ironclaw_filesystem + ironclaw_host_api only), not a self-computed host path. That is the whole point: writer and reader share one MountView, so an attachment landed here is reachable at the same virtual path the agent's file_read/list_dir tools resolve through (this and later turns), and the write requires a MountPermissions write grant: a read-only mount fails closed with PermissionDenied. This structurally prevents the v2 "two roots" bug (writer under one host root, agent mount under another) from recurring. API: - sanitize_attachment_segment: collapse an attacker-controlled string to one safe segment (alphanumerics + . - _; everything else to _; trim leading/trailing dots, neutralizing ".." and hidden-file segments). - attachment_scoped_path: build {alias}/attachments/{date}/{message_id}- {filename} with every user-influenced segment sanitized; ScopedPath::new additionally rejects ".." path segments and raw host paths. - land_attachment: resolve the path + write bytes through ScopedFilesystem, return the ScopedPath. Date passed in (no chrono dep, deterministic). Tests: sanitization (separators/dots/empty/non-ASCII), path construction, traversal contained under the mount, write-then-read round trip through a shared InMemoryBackend (proves discoverability across handles), and fail-closed PermissionDenied on a read-only mount. Deferred to later Track 6 slices: wiring land_attachment into the Reborn inbound path + setting AttachmentRef.storage_key, the model-facing project_path, the per-attachment memory index note, sandbox e2e, and convergence with WASM store_attachment_data.
…guate paths
Address code-quality review findings on the landing crate:
- Remove the `pub const DEFAULT_PROJECT_MOUNT_ALIAS` — it was a third copy
of the `/workspace` alias already owned by the composition layer
(`local_dev_mounts.rs::WORKSPACE_ALIAS`), consumed only by tests, and its
own doc admitted it must stay in sync. Production callers pass the alias
read off the request's MountView, so the crate owns no default that could
drift. Replaced with a test-only `PROJECT_ALIAS`.
- Land attachments at `{message_id}-{index}-{filename}` (1-based index always
rendered) so two same-named attachments on one message no longer silently
overwrite each other. Aligns the `index` doc with actual behavior and adds
a collision-avoidance test.
- Narrow `sanitize_attachment_segment` to module-private (no production
consumer); trim it and the removed alias from the public surface.
Addresses the conventions nit: the crate enforced no lint policy while both its dependencies and the sibling reborn crates warn on unreachable_pub / dead_code. Opt in via [lints] workspace = true so the surface is guarded as the crate grows. (The High same-filename-collision finding is already resolved on this branch: the index is folded into the path and same_named_attachments_on_one_message_never_collide locks it.)
Address review: land_attachment materialized and persisted an unbounded Vec<u8> with no size check — the write-side gap opposite read_bytes_bounded. Add a required max_bytes parameter (with a DEFAULT_MAX_ATTACHMENT_BYTES of 25 MiB callers may use or tighten) and reject over-limit bytes with a new AttachmentLandingError::TooLarge before any write, so an oversized upload can neither materialize past the bound nor grow project storage. Tests: oversize rejection asserts TooLarge and that nothing was written; plus a same-filename land-then-land round-trip proving two attachments sharing a filename keep their own bytes (no clobber) via the index prefix.
9105e29 to
5249ebc
Compare
There was a problem hiding this comment.
Pull request overview
Introduces a new leaf crate (ironclaw_attachments) that provides a single, channel-agnostic way to “land” inbound attachment bytes into the agent-accessible project filesystem via ScopedFilesystem, returning a ScopedPath storage key to persist in attachment refs.
Changes:
- Added
ironclaw_attachmentscrate with path construction, sanitization, and landing/write routine. - Implemented unit + async tests covering sanitization, path containment, write/read round-trip, permission-denied fail-closed behavior, and size limiting.
- Registered the new crate in the workspace (
Cargo.toml) and lockfile (Cargo.lock).
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/ironclaw_attachments/src/lib.rs | New crate root exposing the attachment landing API surface. |
| crates/ironclaw_attachments/src/landing.rs | Core implementation: segment sanitization, scoped path building, bounded write via ScopedFilesystem, plus tests. |
| crates/ironclaw_attachments/Cargo.toml | New crate manifest and dependencies. |
| Cargo.toml | Adds crates/ironclaw_attachments to workspace members. |
| Cargo.lock | Adds the new crate entry and dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Collapse a raw, possibly attacker-controlled string into one safe path | ||
| /// segment: keep ASCII alphanumerics and `.`/`-`/`_`, replace everything else | ||
| /// with `_`, then trim leading/trailing dots (which neutralizes `..` and | ||
| /// hidden-file segments). An empty result becomes `attachment`. | ||
| fn sanitize_attachment_segment(raw: &str) -> String { | ||
| let sanitized: String = raw |
There was a problem hiding this comment.
The PR description was stale — sanitize_attachment_segment is intentionally internal, not public API. Callers land through land_attachment, which sanitizes for them; exposing the raw sanitizer would invite callers to pre-sanitize and double-transform. Updated the description to list only the exported surface and to mark this helper as internal, rather than making it pub.
| mod landing; | ||
|
|
||
| pub use landing::{ | ||
| ATTACHMENTS_DIR, AttachmentLanding, AttachmentLandingError, DEFAULT_MAX_ATTACHMENT_BYTES, | ||
| attachment_scoped_path, land_attachment, | ||
| }; |
There was a problem hiding this comment.
Right — and the fix is to keep it internal rather than re-export it. land_attachment/attachment_scoped_path are the public entry points and they sanitize internally, so there is no reason for a downstream caller to reach the raw segment sanitizer. Corrected the PR description to match the actual (intentional) exported surface.
| pub async fn land_attachment<F>( | ||
| filesystem: &ScopedFilesystem<F>, | ||
| scope: &ResourceScope, | ||
| project_alias: &str, | ||
| date: &str, | ||
| landing: &AttachmentLanding<'_>, | ||
| bytes: Vec<u8>, | ||
| max_bytes: usize, | ||
| ) -> Result<ScopedPath, AttachmentLandingError> |
There was a problem hiding this comment.
Reconciled the description: land_attachment(..., bytes, max_bytes) is the intended signature (the max_bytes bound was added in this PR to reject oversized uploads before any write), and DEFAULT_MAX_ATTACHMENT_BYTES (25 MiB) is exported as the default callers may pass or tighten. I kept max_bytes explicit rather than adding a defaulting wrapper so every call site states its bound — a silent default is exactly what let the unbounded-write gap exist. Description now shows the full signature.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/ironclaw_attachments/src/landing.rs`:
- Around line 64-81: sanitize_attachment_segment currently produces a lossy,
non-unique string which can collide (e.g., "a/b" -> "a_b"), so change the key to
include a short deterministic digest of the original raw input; update
sanitize_attachment_segment (and any callers like attachment_scoped_path) to
produce a readable sanitized base (keep current char-filter + trim logic,
falling back to "attachment" when empty) and append a fixed-length hex digest
(e.g., first 8-12 chars of SHA-256) computed from the raw inputs that affect
identity (message_id and original filename) separated by a delimiter like "--"
or "_" so the stored key remains human-friendly but collision-resistant and
stable across runs; ensure the digest is computed from the raw un-sanitized
strings and that the final returned string is valid for existing storage
constraints.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 977ddf5f-baf0-42b9-8e38-4e51280e8cad
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!**/Cargo.lock
📒 Files selected for processing (4)
Cargo.tomlcrates/ironclaw_attachments/Cargo.tomlcrates/ironclaw_attachments/src/landing.rscrates/ironclaw_attachments/src/lib.rs
Bot review raised a sanitization-aliasing collision concern. It does not apply: path uniqueness is carried by (message_id, index), and message_id is a v4 UUID whose charset sanitize_attachment_segment never alters, so distinct messages cannot alias to one segment and the cosmetic filename segment cannot cause an overwrite. Lock that premise with a direct test.
) (nearai#4668) * feat(attachments): MountView-based attachment landing crate (nearai#4644) Track 6 (byte-storage foundation) of nearai#4644. New leaf crate ironclaw_attachments owns the single, channel-agnostic routine that writes inbound attachment bytes into the agent-accessible project filesystem and returns the ScopedPath to record as an attachment's storage key. The write goes THROUGH the project-scoped ScopedFilesystem authority (deps: ironclaw_filesystem + ironclaw_host_api only), not a self-computed host path. That is the whole point: writer and reader share one MountView, so an attachment landed here is reachable at the same virtual path the agent's file_read/list_dir tools resolve through (this and later turns), and the write requires a MountPermissions write grant: a read-only mount fails closed with PermissionDenied. This structurally prevents the v2 "two roots" bug (writer under one host root, agent mount under another) from recurring. API: - sanitize_attachment_segment: collapse an attacker-controlled string to one safe segment (alphanumerics + . - _; everything else to _; trim leading/trailing dots, neutralizing ".." and hidden-file segments). - attachment_scoped_path: build {alias}/attachments/{date}/{message_id}- {filename} with every user-influenced segment sanitized; ScopedPath::new additionally rejects ".." path segments and raw host paths. - land_attachment: resolve the path + write bytes through ScopedFilesystem, return the ScopedPath. Date passed in (no chrono dep, deterministic). Tests: sanitization (separators/dots/empty/non-ASCII), path construction, traversal contained under the mount, write-then-read round trip through a shared InMemoryBackend (proves discoverability across handles), and fail-closed PermissionDenied on a read-only mount. Deferred to later Track 6 slices: wiring land_attachment into the Reborn inbound path + setting AttachmentRef.storage_key, the model-facing project_path, the per-attachment memory index note, sandbox e2e, and convergence with WASM store_attachment_data. * refactor(attachments): drop duplicate mount alias, make index disambiguate paths Address code-quality review findings on the landing crate: - Remove the `pub const DEFAULT_PROJECT_MOUNT_ALIAS` — it was a third copy of the `/workspace` alias already owned by the composition layer (`local_dev_mounts.rs::WORKSPACE_ALIAS`), consumed only by tests, and its own doc admitted it must stay in sync. Production callers pass the alias read off the request's MountView, so the crate owns no default that could drift. Replaced with a test-only `PROJECT_ALIAS`. - Land attachments at `{message_id}-{index}-{filename}` (1-based index always rendered) so two same-named attachments on one message no longer silently overwrite each other. Aligns the `index` doc with actual behavior and adds a collision-avoidance test. - Narrow `sanitize_attachment_segment` to module-private (no production consumer); trim it and the removed alias from the public surface. * chore(attachments): opt into workspace lints Addresses the conventions nit: the crate enforced no lint policy while both its dependencies and the sibling reborn crates warn on unreachable_pub / dead_code. Opt in via [lints] workspace = true so the surface is guarded as the crate grows. (The High same-filename-collision finding is already resolved on this branch: the index is folded into the path and same_named_attachments_on_one_message_never_collide locks it.) * feat(attachments): bound landed attachment size before writing Address review: land_attachment materialized and persisted an unbounded Vec<u8> with no size check — the write-side gap opposite read_bytes_bounded. Add a required max_bytes parameter (with a DEFAULT_MAX_ATTACHMENT_BYTES of 25 MiB callers may use or tighten) and reject over-limit bytes with a new AttachmentLandingError::TooLarge before any write, so an oversized upload can neither materialize past the bound nor grow project storage. Tests: oversize rejection asserts TooLarge and that nothing was written; plus a same-filename land-then-land round-trip proving two attachments sharing a filename keep their own bytes (no clobber) via the index prefix. * test(attachments): pin uuid message_id sanitization-stability invariant Bot review raised a sanitization-aliasing collision concern. It does not apply: path uniqueness is carried by (message_id, index), and message_id is a v4 UUID whose charset sanitize_attachment_segment never alters, so distinct messages cannot alias to one segment and the cosmetic filename segment cannot cause an overwrite. Lock that premise with a direct test.
Summary
Track 6 (byte-storage foundation) of #4644, originally stacked on Track 2 (#4655, now merged). We reordered Track 6 ahead of the rest of Track 3 because model-visibility of attachments depends on bytes living in storage —
ProductAttachmentDescriptoris bytes-free by design, so an image/doc can only reach the model by being read back from the project filesystem. This PR builds that storage foundation.New leaf crate
ironclaw_attachmentsowns the single, channel-agnostic routine that writes inbound attachment bytes into the agent-accessible project filesystem and returns theScopedPathto record as an attachment's storage key.Scope:
crates/only, nosrc/changes.Why through the MountView authority
The write goes through the project-scoped
ScopedFilesystem(deps:ironclaw_filesystem+ironclaw_host_apionly), not a self-computed host path. Writer and reader share oneMountView, so:file_read/list_dirtools resolve through (this and later turns);MountPermissionswrite grant — a read-only mount fails closed withPermissionDenied, enforced insideScopedFilesystembefore any backend dispatch.This structurally prevents the v2 "two roots" bug (attachments under one host root, the agent's
/project/mount under another) from recurring — the writer and the reader literally cannot diverge.API (exported surface)
attachment_scoped_path(project_alias, date, &AttachmentLanding) -> Result<ScopedPath>— build{alias}/attachments/{date}/{message_id}-{index}-{filename}with every user-influenced segment sanitized. The 1-basedindexprefix makes two attachments in one message land at distinct paths even when they share a filename; uniqueness is carried by(message_id, index), never by the (cosmetic) filename segment.ScopedPath::newadditionally rejects..path segments and raw host paths. Date is passed in (nochronodep, fully deterministic).land_attachment(&ScopedFilesystem<F>, &ResourceScope, project_alias, date, &AttachmentLanding, bytes, max_bytes) -> Result<ScopedPath>— reject over-max_bytesinput withAttachmentLandingError::TooLargebefore any write, then resolve the path + write the bytes through the authority; return theScopedPathstorage key.DEFAULT_MAX_ATTACHMENT_BYTES(25 MiB) — a default size cap callers may pass toland_attachmentor tighten to their provider's limit.AttachmentLanding,AttachmentLandingError,ATTACHMENTS_DIR.sanitize_attachment_segmentis an internal helper (collapse an attacker-controlled string to one safe segment: alphanumerics +.-_; everything else →_; trim leading/trailing dots, neutralizing..and hidden-file segments). It is intentionally not exported — callers land throughland_attachment, which sanitizes for them.Tests
9 tests: sanitization (separators / interior-vs-edge dots / empty / non-ASCII); UUID
message_idis sanitization-stable so distinct messages never alias; path construction + synthesized filename; same-named attachments on one message land at distinct paths; traversal contained under the mount (no..segment escapes); write→read round-trip through a sharedInMemoryBackend; same-filename land-then-land round-trip proving no clobber (both byte sets survive); oversize rejection assertingTooLargeand that nothing was written; and fail-closedPermissionDeniedon a read-only mount.Deferred (later Track 6 + Track 3 slices)
land_attachmentinto the Reborn inbound path and setAttachmentRef.storage_key(Track 2's field) to the returnedScopedPath; that caller enforcesmax_byteson a streaming reader before buffering the fullVec<u8>.memory_searchdiscoverability).project_pathsofile_readresolves it; sandbox (SANDBOX_ENABLED=true) integration test; convergence with WASMstore_attachment_data.content_parts,chat_workflow.rs[non_text_content]fix) now have a place to read bytes from.Stacking
Rebased onto
mainafter #4655 merged (only this crate's own commits replayed). Brand-new crate, code-independent of Tracks 1–2.Summary by CodeRabbit
Release Notes