Skip to content

core: Failed(String) has no source, and the server forwards core's message to clients verbatim #66

Description

@otsobide

Rewritten 2026-08-26 against v0.8.0. The original body led with a 7z reproduction that no longer happens: layer 1 below was fixed in #84 and the messages it complained about are gone. Anyone trying to reproduce it would have concluded the issue was stale. What remains is the design half, which is real and untouched, plus one leak the original only mentioned in passing. History is in the edit log.

What is already fixed

from_sevenz (apps/core/src/compression/sevenz.rs:37) maps each sevenz_rust2::Error variant by hand instead of stringifying it, so the three backends now agree:

$ collapse compress notes.txt -f zip -o missing/out.zip
error: IO error: No such file or directory (os error 2)
$ collapse compress notes.txt -f tar -o missing/out.tar
error: IO error: No such file or directory (os error 2)
$ collapse compress notes.txt -f 7z  -o missing/out.7z
error: IO error: No such file or directory (os error 2)     <- was: Io(Os { code: 2, ... }, "/…/out.7z")

and a damaged archive is described in words rather than as a byte array:

$ collapse extract corrupt.7z -o x
error: Compression failed: not a 7z archive: the file does not start with a 7z signature

CompressionError also grew real structure since this was filed: VerificationFailed { archive, reason }, Entry { entry, destination, #[source] source } and Name(#[from] NameError) are variants rather than strings. So the "everything is one Failed(String)" premise is no longer accurate either.

What is still true

Failed(String) remains the catch-all for third-party errors, with no #[source]. A corrupt archive, a full disk and a rejected traversal are still one variant separated only by substring matching, and no caller can classify a failure or walk the chain. docs/architecture.md still documents this as a deliberate trade-off, which it is; the question is whether it has been paid for long enough.

Three shapes, unchanged from the original analysis, none obviously right:

  • Failed { message: String, #[source] source: Box<dyn Error + Send + Sync> }. Display is unchanged, so no consumer's messages move, and callers who care can walk the chain. Cheapest for the CLI, the desktop and the server.
  • Per-backend variants (Zip(#[from] zip::result::ZipError) and so on). Most precise, but it puts third-party types in core's public API, so a dependency bump becomes a breaking change for collapse-cli and the desktop crate.
  • A semantic enum the backends map into (CorruptArchive, Unsupported, Traversal, Io, Other). Best for callers, most work, and it needs an answer for whatever fits none of the buckets.

The server still hands a core message to remote clients, and one path bypasses the curation entirely. failure_message (apps/server-backend/src/error.rs:67) curates VerificationFailed and lets everything else fall through as other.to_string(). Worse, queue.rs:116 does not even call it:

extract_tar(input, tree).map_err(|e| e.to_string())?;

so unpacking a client's tar envelope forwards core's message verbatim into the job's error_message, which GET /jobs/{id} returns. Reproduced on this machine with a tar whose entry has a file for a parent:

error: Compression failed: failed to unpack `/…/out/root/a/b`

That is the server's absolute staging path going to an unauthenticated caller. docs/threat_model.md section 9 still has no entry for error-message disclosure.

Note CompressionError::Entry renders destination.display() too. On the server it is currently unreachable (it fires only when renaming, and Linux rules reject nothing but NUL), so it is not a second live leak today, but it is a second way the same thing could start happening.

Suggested split

These are independent and the second is much smaller than the first:

  1. The design question. Pick one of the three shapes above. Wants a decision before code.
  2. Stop the server forwarding raw core messages. Route every failure through failure_message, including the extract_tar call at queue.rs:116, and have it return a curated sentence with the full message going to the log. That is exactly the pattern the registry already uses for an unreadable row (docs/registry.md), so it is a known shape rather than a new one. Add a case in apps/server-backend/tests/api.rs asserting a failed job's error_message names no filesystem path, and a threat_model.md entry either way.

Piece 2 is worth doing on its own and does not wait on piece 1.

Done when

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions