Skip to content

Release v0.8.0 - #98

Merged
otsobide merged 29 commits into
mainfrom
dev
Aug 26, 2026
Merged

Release v0.8.0#98
otsobide merged 29 commits into
mainfrom
dev

Conversation

@otsobide

Copy link
Copy Markdown
Owner

Release of everything on dev since v0.7.0: six feature PRs plus the version bump. Version strings and lockfiles are already at 0.8.0, which is what the release guard checks before it will publish.

Features

Post-compression verification (#86). An archive is now checked before it replaces anything at output, at one of two depths. The shallow pass reads the archive's own listing back and compares it against the entries it was meant to hold, which decompresses nothing and catches the failure this exists for: a compression that died half way through and finalised a valid-looking archive anyway. The deep pass decompresses every entry as well, so zip's and 7z's per-entry checksums are checked; tar stores no checksum over entry data, so there it can only confirm the archive is complete and well formed. It roughly doubles the work, which is why it is the user's call: a CLI flag, a desktop checkbox and an HTTP parameter.

Entry names the host cannot write (#87). An archive built on Linux can carry names Windows refuses outright or, worse, silently reinterprets. Extraction now judges every name against the host's rules before writing anything and refuses rather than guessing; the desktop asks the user which character should stand in for which, one field per character, and extracts with those answers. The whole listing is planned before the first byte, so an unanswerable name leaves the output directory as it was found.

Fixes

Archive entry names are no longer split with the host's path parser (#88). They were, and std::path is #[cfg]-dependent while the rules judging them are data, so the seam was only half portable. On Unix a backslash is a legal file name character that the splitter did not treat as a separator, so collapse could build an archive it then refused to extract, failing after the user might have deleted the originals. On Windows a leading drive-like component was silently discarded, so a:b/c.txt was reported clean and written as b/c.txt: a hole in the colon handling that #63 exists for, on the only platform #63 concerns. An entry name is not a host path, so the split is now on / everywhere.

Path traversal in zip and 7z (#88). Both sanitized an entry name, joined it to the output and wrote, with no check on where the join landed. With a symlink already present in the output directory, an archive holding link/evil.txt wrote straight through it and returned success. tar was immune because unpack_in resolves the parent; zip and 7z now do the same. This one predates v0.7.0 and is fixed for the first time here. It needs a symlink already in the destination, so a hostile archive cannot trigger it alone, but extracting into a directory that already has one is ordinary.

Smaller (#85, #84, #81). Archive extensions read case-insensitively, so Photos.ZIP opens; three small fixes; the Tauri commands moved off the UI thread so the window keeps repainting during a long job.

Verification

585 Rust tests and 113 Vitest, on three platforms rather than one. The macOS and Windows legs run on shipping branches, which is what caught #88 in the first place, and both are green on this branch.

Closes #63
Closes #64

Note

Only those two close. Nothing else on the board is finished, and a release does not change that.

otsobide and others added 29 commits August 22, 2026 13:06
A bare #[tauri::command] on a synchronous function compiles to what
tauri-macros calls the sync path, which runs the body inline on the
thread handling the IPC message. Every compression froze the window for
its whole duration, and check_server froze it for the 30 seconds of
ureq's connect timeout whenever someone mistyped an address in the
settings panel. The doc comment on compress_path claimed the opposite,
that Tauri ran it on a blocking pool and the window stayed responsive.

compress_path, extract_archive and check_server now carry
#[tauri::command(async)]. is_directory does not: it is one stat, called
while the user is still choosing, so the round trip would cost more than
the call.

The module header records what the attribute does and, as importantly,
what it does not: it moves the body to the async runtime, a multi-thread
tokio with one worker per core, not to the blocking pool, so the body
occupies a worker for its whole duration. That is fine while the UI runs
one operation at a time and disables itself meanwhile; anyone wanting
several at once should reach for spawn_blocking rather than add more of
these.

Pinned by a test in tests/ipc.rs, which is the only place it can be
pinned: a command is an ordinary function, so every other test calls
these directly and gets the same answer whatever the attribute says.
Every command has to appear in one of its two lists, so whether a new
one can freeze the window is something someone decided rather than
something nobody noticed. Verified falsifiable: removing the attribute
from compress_path turns it red.
Take the commands that can block off the UI thread
reap called Storage::delete_job as a statement, forgot the row and
counted the job regardless, so a removal that failed left files with no
row: exactly the leak reconcile exists to clean up, recreated by the
reaper, while the log said it had collected them. Reproduced by making a
job's staging directory unwritable and watching the sweep report
reaped=1 with the archive still on disk.

The conflation is fixed at its source rather than worked around at each
call site. delete_job returned a bare bool for two different outcomes,
the directory was not there and the removal failed, and it now returns
io::Result<bool>: Ok(true) removed it, Ok(false) there was nothing to
remove, Err says why. That also closes the gap between the exists check
and the removal, which were two separate questions to the filesystem.

reap forgets the row and counts it when the files are gone, and on a
failure keeps the row, warns with the reason and counts it separately so
the next sweep retries. The trade-off is recorded where it is made: a
kept row also keeps the startup pass off that directory, so a permanent
failure leaks until the reaper wins, which is accepted because the usual
failure is transient and a loud retry self-heals it, while forgetting
the row would leave the API advertising a job whose archive is half
deleted.

The log line reports both numbers, following the shape Reconciled
already had. The principle was already written in this codebase, in the
comment above the same situation in reconcile: a report that claims a
cleanup it did not do is worse than one that admits the problem.

Closes #78.
sevenz-rust2 implements Display as Debug, and core called to_string() on
it, so a 7z failure reached the user as a struct dump with the absolute
path inside it. The same mistake that gave zip and tar
"IO error: No such file or directory (os error 2)" gave 7z
Io(Os { code: 2, kind: NotFound, message: "..." }, "/Users/...").

One mapping function now covers the dependency's error enum. The
variants that carry an io::Error become CompressionError::Io, so 7z
finally agrees with the other two; the rest get hand-written sentences
with nothing in them that could be a Debug form or a path.

The match is exhaustive on purpose: a dependency bump that adds a
variant should break the build rather than fall through a catch-all back
into a dump.

Two writer.finish() calls lost their map_err entirely, because
SevenZWriter::finish returns io::Result rather than the crate's own
error, so that code was stringifying a plain io::Error for no reason.

The desktop test that special-cased 7z out of its "IO error" assertion
is simpler now that it does not have to.

Layer 2 of the issue, giving CompressionError a #[source] so callers can
tell a corrupt archive from a full disk, is a wider design choice and
stays open.

Refs #66.
…ocal run

The dispatch was server.as_deref().filter(|s| !s.is_empty()), so the two
front ends disagreed about the same string: the desktop turned Some("")
into a local compression and Some("   ") into a network attempt against
an empty address, while the CLI sent both. A stale value in localStorage
or a wrapper script with an unset variable landed wherever the front end
happened to decide.

The answer moves into collapse-remote, which is the argument that
created the crate and the same move that fixed the path guards in
v0.7.0: an address that is blank or only whitespace is rejected at
compress_path and check_health, so no front end can forget, and the
message names the mistake and shows what a real address looks like.

An error rather than a quiet local run, for both. The desktop UI never
sends a blank (urlFor returns null for this computer and a real URL
otherwise), so one can only arrive from a stale stored value or a
caller's bug, and compressing locally would hide it. On the CLI,
--server "   " is a flag someone typed wrong, and saying so beats
guessing what they meant.

The two tests that pinned the old contract, one of them marked KNOWN
DEFECT, now assert the refusal.

Closes #65.
434 Rust tests now, 342 of them in the workspace.
The blank guard asked is_empty of the trimmed input but returned the
untrimmed one, so two shapes that are not addresses got through.

"///" has no whitespace to trim, so it was not blank; trimming its
slashes afterwards left an empty base, and every endpoint was joined
onto nothing. The user was told "cannot reach the server at : ", naming
a server with no name, which is the message issue #65 was filed about,
reached by the one road the fix did not cover.

And a trailing space defeated the slash trim, so "http://host:8000/ "
kept the separator base_url exists to remove.

Normalizing before deciding folds both into the same refusal, and the
order is the point: trimming the whitespace first is what lets the slash
trim see the slash.

Found by an adversarial coverage pass over the fix that introduced it,
which pinned both as known defects. Those two pins, and a third in
tests/client.rs that asserted the nameless-server message where a user
meets it, now assert the refusal instead.
An adversarial pass over each fix, starting from the diff rather than
from the tests: enumerate what the change alters, then ask which of
those a test would catch. Every gap below was confirmed by mutation, by
making the change and watching the suite stay green.

reconcile was adapted to the new io::Result and its error arm was
untested, which is the same class of bug this branch fixes, in the
function that inspired the fix. Two mutations survived: counting an
orphan it could not remove, and returning the error, which would make
the server refuse to boot over one unremovable directory.

Reaped::is_quiet was only ever read on an all-zero report, so
self.collected == 0 passed everything: a sweep that collected nothing
but could not remove a job would have logged nothing at all, which is
the exact silence the fix exists to end. No pass had more than one
expired job either, so giving up at the first failure was invisible.
DELETE /jobs/{id} had no test through the HTTP surface for either arm of
its new match.

For 7z, compress_7z_dir's three call sites had no coverage at all: the
whole-directory half of the backend could revert to to_string() on its
own with every test still green. Two existing tests asserted only
is_err(), which cannot tell a sentence from a struct dump.

For the blank address, what "blank" means was never pinned beyond three
characters, the refusal was proven to precede reading a file but not
packing a directory (the ordering the guard's comment justifies itself
with), and dropping the desktop's filter changed what Some(_) means
while only the blank third of that was tested: nothing said a real
address still crosses the wire.

Also strengthened: several assertions used contains where the message is
the contract, and could not have failed for the mutation they looked
like they guarded.
459 Rust tests now, 365 of them in the workspace.
Three small fixes: the reaper's accounting, 7z error messages, blank server addresses
Photos.ZIP was refused as an unknown format. The extension is a file
name, not a wire value: Windows and macOS fold case in the filesystem
and plenty of tools write .ZIP, so a perfectly good archive was
unreadable for the spelling of its name alone. The same match also
drives the CLI's format inference, so -o backup.7Z fell through to the
zip default and wrote a zip under a name promising a 7z, an archive this
same CLI then refused to extract.

Case folding is ASCII rather than Unicode: the three extensions are
ASCII, and Unicode folding has surprises (Turkish dotless i among them)
that have no business deciding an archive format.

Two things deliberately left alone, and now pinned so they stay that
way. FromStr keeps its strict match: it parses the algorithm= query
parameter of POST /compress and the CLI's --format, both wire values
with a documented enum, and loosening it would silently widen the API.
And extension() keeps returning lowercase, since it names the files this
toolkit writes, so a case-insensitive reader can never start producing
photos.ZIP.

Magic-byte sniffing is a different feature (it buys archives with the
wrong extension or none at all) with real decisions attached, and stays
in the issue.

The desktop test that pinned the old refusal as a KNOWN LIMITATION now
asserts the fix, and checks that the complaint about a .7Z holding zip
bytes is about the bytes rather than the name, which is what tells the
two apart. Core had no test for any of this and now has five; the CLI
has one for the inference road.

Closes #80.
Read an archive's extension whatever its case
…it in place

Issue #70: a compression that fails midway left a partial archive, and
for zip and tar the leftover was a VALID archive silently missing
entries, because both finalise on drop. The user saw an error, opened
the archive, found it opened fine, and could delete the originals.

Two pieces, and they only work together.

The archive is written to a staging file in the same directory as the
output and renamed into place once it is known good. A guard removes it
on any early return, so a failure, a panic or a rejected verification
cannot leave anything at the destination or beside it. That also ends
the hardlink write-through pinned as a KNOWN LIMITATION, and makes a
failed run leave a previous archive untouched on the local path as it
already did on the remote one.

Before that rename, the archive is checked at one of two depths.
Verify::Index reads its listing back and confirms it names exactly the
entries that were meant to go in, decompressing nothing: it costs
milliseconds and it is what catches the bug above. Verify::Contents also
decompresses every entry into a sink, never to disk, and is the caller's
choice because it roughly doubles the work.

What Contents buys differs by format and the doc comment says so rather
than implying otherwise: zip carries a CRC32 per entry and 7z a CRC per
file, both checked on read, while tar carries nothing over an entry's
data, so for tar it can only mean reading every entry through and
confirming the archive is well formed. A test pins that difference, so
nobody can quietly start claiming more.

An enum rather than a bool, because compress(.., 3, true) says nothing
where it is called. Verification failures get their own error variant:
a caller should be able to tell 'the archive I just wrote is wrong' from
'the compressor failed'.

Two latent bugs surfaced while writing the tests and are fixed here.
compress_zip created the output before opening the source, which is the
documented zero-byte .zip quirk, now gone. And compress_zip_dir named
each member in the archive before reading it, so a member that could not
be read still appeared as an entry with the CRC of nothing behind it: an
archive no reader could fault, holding an empty file where a real one
belonged. That one would have defeated Contents verification too, since
the checksum matched the emptiness.

The CLI gains --verify and the server a verify query parameter, both off
by default. They move in this commit because the signature change makes
them one edit: the workspace does not compile split apart.

For the server that meant a schema migration, since the worker receives
only a job id and reads the rest from the registry. SCHEMA_VERSION is 3,
following how v1 added server_version: a store written by an older build
opens and works, one written by a newer build is still refused.

Refs #70.
A Verify row beside Where, Format and Level, off by default, with a hint
that says the cheap half always runs: 'The archive's listing is always
checked before it is saved.' Without that line the checkbox would read
as the difference between checking and not checking, which is not what
it is.

Disabled when the destination is a server, because the compression
happens there and the box would promise something this machine cannot
do. Absent in extract mode along with the rest of the compress options,
which is what that panel already did.

The window is 80px taller so the new row fits without the panel
clipping at the default size.

Three things had to move together, and nothing type checks the crossing:
generate_handler!, the invoke payload in App.vue and the stub switch in
the Vitest suite. tests/ipc.rs pins that crossing including parameter
types, so its frozen signature table carries the new argument and would
have failed if any side had been forgotten.

Refs #70.
architecture.md gains what the two depths check and, more usefully, what
they cannot: tar carries no checksum over an entry's data, so Contents
means something weaker there than it does for zip and 7z.

server.md documents the query parameter, registry.md the third schema
version and what an older or newer store does on open, and the README
counts move to 519 Rust tests.
Verify an archive before it replaces anything
Extracting an archive whose entry name the host cannot write was broken
two ways. A name that is legal on Unix but not on Windows died with an
opaque OS message and left half a directory behind. And a name holding a
colon was worse, because Windows ACCEPTS it: notes.txt:hidden becomes a
stream attached to notes.txt rather than a file, silently, and the
listing named an entry that existed as no file.

Both issues say to decide them together, and both list the same three
answers: refuse, sanitize blindly, or skip what cannot be written. The
answer taken here is a fourth, and it is why this is worth the size: ask
the caller. The engine says what it cannot write and why, and takes a
map from offending character to replacement.

The rules are data, not cfg. NameRules::windows() can be asked for on
any platform, so every Windows rule is tested from a Mac, and only
NameRules::host() is chosen by the compiler. A rule reachable solely
under #[cfg(windows)] is a rule this repository cannot test, and that
habit is what left a data-loss guard broken there for months.

The Windows rules were read rather than remembered, and the tests pin
what memory gets wrong: a device name is matched without its extension
(CON.txt is reserved), case insensitively, splitting on the FIRST dot
(NUL.tar.gz), and without catching COM10 or CONSOLE.txt. A colon is
reported as reinterpreted rather than rejected, because the danger is
that the write succeeds.

Replacements are applied before the structural adjustments, which is not
the obvious order: CO?1 answered with M would otherwise be left as the
device COM1. An answer is validated (it cannot be unwritable itself, nor
carry a path separator, nor leave an empty component or ..), and a
collision is refused naming both entries rather than silently renaming
one, which is how a file goes missing without anyone noticing.

Extraction returns the names actually written, never the archive's, or a
front end would list files that are not on disk. tar needed its own
containment guard: it never called sanitize_entry_path, so the check zip
and 7z share had no equivalent there.

Per-entry write errors now name the entry and the destination. That is
piece one of the issue and it helps everywhere: today a read-only output
directory, a full disk and a permission error all produce the same blank
message.

extract(archive, output_dir) keeps working for callers with nothing to
answer, so nothing had to change at once.

Refs #63, #64.
The flow the feature is for: choose an archive, choose a destination,
and if it holds names this computer cannot write, nothing is extracted.
A sheet says what is wrong and offers one text field per offending
character, prefilled, whatever the number of entries carrying it. Answer
and it extracts; cancel and nothing was touched.

One field per character rather than per entry, because a hundred files
holding a question mark is one question, not a hundred.

The problems with no character to replace, a trailing dot or space and a
reserved device name, are stated with the adjustment that will be
applied rather than given a field that asks nothing.

Refusals come back into the sheet rather than out to the error banner: a
replacement this computer cannot write either, and a set of answers that
would land two entries on one name, both keep the question open with the
reason, because they are answerable. Anything else is a real failure and
belongs in the banner.

Inspecting is its own command now, so the archive is read before a byte
of it is written. That is a second crossing of an untyped boundary, and
tests/ipc.rs pins both including parameter types.

Verified by the Vitest suite rather than by looking: this sheet cannot
appear on macOS, since the rules are the host's and macOS writes these
names happily. The test that matters asserts extract_archive is never
called before the question is answered, which a component that extracted
first and asked afterwards would fail while passing everything else.
No dialog here: a CLI cannot ask mid-run without becoming interactive,
which this one is not. What it can do is fail well, which it did not.
An archive holding a name Windows cannot write died with a bare OS
message naming nothing, after writing part of the archive.

Extraction now refuses up front, before anything is written, listing the
entries it cannot write and why each one is a problem. Ordinary write
failures name the entry too, so a read-only output directory and a full
disk stop being indistinguishable.
architecture.md gains the new core module, and says the thing worth
remembering about it: the rules are data rather than cfg, so the Windows
behaviour is testable from any machine.

578 Rust tests now, 463 of them in the workspace.
Ask which characters to replace, instead of failing or guessing
`normal_components` was `Path::new(name).components()`, and `std::path` is
`#[cfg]`-dependent while `NameRules` is data. The rules were portable; the
splitting they ran over was not, so the seam only modelled Windows half way
and broke Unix outright.

Three failures, one cause:

- On Unix a backslash is an ordinary character, so `dir\f.txt` was one
  component that `rewrite` then refused as "not a name this system can hold".
  A legal Unix file name became an archive collapse could build and then
  would not extract, failing at extract time with nothing written. All three
  formats, through the public `extract`.
- On Windows a leading `a:` parses as a drive prefix, and a prefix is not a
  `Normal` component, so it was silently discarded: `a:b/c.txt` was judged,
  reported and written as `b/c.txt`. The report therefore never asked about
  that colon, which is the hole issue #63 exists to close, on the only
  platform issue #63 is about.
- CI caught the same thing as a Windows-only test failure:
  `rewrite_entry("a:b/CON/c?.txt.")` answered `b\CON_\c_.txt`.

An archive entry name is not a host path. ZIP mandates the forward slash
(APPNOTE 4.4.17.1) and tar has used it since v7, so `entry_components` splits
on `/` on every machine and `NameRules::windows()` now answers the same
question from a Mac as from Windows.

That makes the backslash an ordinary character inside a component, which is
exactly what it is, so it moves into WINDOWS_REJECTED where Unix keeps
allowing it and Windows does not.

It also removes the post-rewrite `contains('\\')` check, which was dead code
except when it was wrong: `check_replacement` already refuses both separators
before either is pushed, so the test could not fire for its stated reason,
and the only thing it ever caught was a name Unix holds perfectly well. The
`/` half stays, structurally, since no ruleset lists the separator.
zip and 7z sanitized an entry name, joined it to the output and wrote. There
was no check that what they were about to write into was still inside, which
tar has always had through `unpack_in`'s `validate_inside_dst`. Two ways past
the lexical guard, and the first is not hypothetical:

- A symlink already sitting in the output directory. Extracting an archive
  holding `link/evil.txt` into a directory that contains a symlink named
  `link` wrote straight through it and reported success. Measured before this
  change: `Ok(["link/evil.txt"])`, with the file outside the chosen output.
  We never create symlinks ourselves, but extracting into a directory that
  already has one is ordinary. tar was immune; zip and 7z were not, and this
  predates the naming work.
- On Windows, a component that parses as a drive. `PathBuf::push` replaces
  what it holds when handed a path carrying a prefix, and a prefix is parsed
  only at the head of a path, so `docs/c:evil.txt` offers `sanitize_entry_path`
  no `Prefix` component to reject, yet pushing the second component discards
  `docs` and leaves a drive-relative path resolving against the current
  directory of C:.

`ensure_inside` resolves the directory just created and requires it to still
be under the output, at every zip and 7z write site. `sanitize_entry_path`
additionally re-parses each part and demands it still be exactly one `Normal`
component, which closes the drive case at the source and leaves Unix, where
the same string is an ordinary file name, untouched.
Four tests over the splitting itself, three of which fail against the old
`Path::components` splitter on this Mac and would have caught the Unix
regression before it merged. The fourth pins the Windows half, which no
machine here can fail, so that the two can never diverge again.

Two containment tests in security.rs for the symlink already sitting in the
output directory, one on the untouched write path and one on the planned
rename, plus a cross-platform pin for a colon in a non-leading component.
Both symlink tests fail without `ensure_inside`.

Also fixes the two assertions that failed on Windows CI, here and the twin in
the CLI suite that the same run never reached because make stops at the first
red app. They compared the error message against the path the test built,
while the message is rendered from a canonicalized root: on Windows that
carries a `\?\` verbatim prefix and expands the runner's 8.3 short name, so
the assertion was comparing against a path production never prints. They now
compare against what extraction actually resolved, which is the thing worth
proving.
`nameProblem` is rendered only inside the naming sheet, and the sheet only
opens when the pre-flight report found something to ask about. The two are
separate passes over the archive and they can disagree: a listing the first
pass could not read is reported as "nothing to ask", and extraction then
refuses a name. With no sheet to hold the message, the Extract button did
nothing at all, and said nothing about it.

A refusal with a dialog open is still a question and still belongs in the
sheet. Without one it goes to the error banner, which is where "this did not
work" already lives.
threat_model.md had nothing about entry names the host cannot write, which
was the last piece of issue #63 still owed. It now covers the colon and the
NTFS stream it becomes, the quieter members of the same family, and why the
rules are data rather than `#[cfg]`.

It also records the hole that shipped in v0.7.0 and how it was closed, since
the interesting part is not that the rules were right but that what they ran
over was not, and a reader deciding whether to trust the defence should be
able to see that failure mode named.

Section 2 gains the case it was quietly missing: a symlink the archive did
not bring, already sitting in the output directory. Only tar defended against
that; the sentence about `unpack_in` was describing tar's guard as if it were
everyone's.

Counts: 585 Rust and 113 Vitest, 470 offline.
Split an archive entry the way the format defines, not the host
All eight version strings and the four lockfiles that carry them. The release
guard checks only `apps/cli/Cargo.toml` and `tauri.conf.json` (issue #77), so
the other six are bumped by hand and by eye until that is fixed.

Minor rather than patch: two features landed since v0.7.0 (post-compression
verification, and asking the user which characters to substitute in a name the
host cannot write), alongside a fix for writing outside the output directory.
@otsobide
otsobide merged commit a74844a into main Aug 26, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant