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:
- The design question. Pick one of the three shapes above. Wants a decision before code.
- 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
What is already fixed
from_sevenz(apps/core/src/compression/sevenz.rs:37) maps eachsevenz_rust2::Errorvariant by hand instead of stringifying it, so the three backends now agree:and a damaged archive is described in words rather than as a byte array:
CompressionErroralso grew real structure since this was filed:VerificationFailed { archive, reason },Entry { entry, destination, #[source] source }andName(#[from] NameError)are variants rather than strings. So the "everything is oneFailed(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.mdstill 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> }.Displayis unchanged, so no consumer's messages move, and callers who care can walk the chain. Cheapest for the CLI, the desktop and the server.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 forcollapse-cliand the desktop crate.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) curatesVerificationFailedand lets everything else fall through asother.to_string(). Worse,queue.rs:116does not even call it:so unpacking a client's tar envelope forwards core's message verbatim into the job's
error_message, whichGET /jobs/{id}returns. Reproduced on this machine with a tar whose entry has a file for a parent:That is the server's absolute staging path going to an unauthenticated caller.
docs/threat_model.mdsection 9 still has no entry for error-message disclosure.Note
CompressionError::Entryrendersdestination.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:
failure_message, including theextract_tarcall atqueue.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 inapps/server-backend/tests/api.rsasserting a failed job'serror_messagenames no filesystem path, and athreat_model.mdentry either way.Piece 2 is worth doing on its own and does not wait on piece 1.
Done when
CompressionErrorlets a caller distinguish a corrupt archive from an IO failure without matching on substrings.error_messagereturned by the server contains a filesystem path, pinned by a test.docs/threat_model.mdsays what the server does and does not disclose in an error.7z agrees with zip and tar on a missing output directory(Three small fixes: the reaper's accounting, 7z error messages, blank server addresses #84)No message from core contains(Three small fixes: the reaper's accounting, 7z error messages, blank server addresses #84)Debug-shaped textThe two desktop tests that asserted the Debug spelling are inverted(Three small fixes: the reaper's accounting, 7z error messages, blank server addresses #84;commands.rs:1546now assertsIO error: failed to fill whole buffer)