Skip to content

reveal: keep the original stego file when nothing was extracted - #36

Merged
cenglin123 merged 1 commit into
mainfrom
fix/reveal-keep-original-20260921
Sep 20, 2026
Merged

cenglin123 merged 1 commit into
mainfrom
fix/reveal-keep-original-20260921

Conversation

@cenglin123

Copy link
Copy Markdown
Owner

The defect

reveal_file() removed the input whenever an extraction method reported success, without checking that anything had reached the disk. Several shapes make a method return True while writing zero files:

  • an archive whose members are all directory entries (zipping an empty folder produces exactly this),
  • an empty archive,
  • member names that sanitize_path() rejects.

The archive inside the stego file is then the only copy left — and it was deleted.

The fix

The decision now rests on a before/after snapshot of the output directory (relative path → size and mtime in ns), not on the methods' return values. The input file is excluded from the snapshot, as are the 7z_ / 7z_unzip_ temp directories _extract_with_7zip creates — those hold intermediates, not results, and counting them would reintroduce the deletion this guard exists to prevent.

When a method reports success and nothing appeared, the original is kept and the reason is logged, instead of falling through to the generic "all methods failed" list, which would be misleading — a method did succeed.

A wrong first attempt, worth recording

A per-call counter incremented at the four places that write extracted files was tried first, and it was wrong. 7-Zip is invoked with -o<output_dir> when auto-rename is off, so a subprocess writes the results and no in-process counter sees them. That version passed the "nothing extracted" case but broke the default for a normal reveal — the original survived a successful extraction. The snapshot covers subprocess writers too, which is why it is the mechanism.

Verification

New scripts/test-reveal-keeps-original.py, run in CI. It covers both halves, so a guard that simply stopped deleting anything could not pass:

Case Expected
archive with only a directory entry → reveal ✅ original kept (previously deleted)
normal reveal (default) ✅ original deleted, payload byte-identical
normal reveal with --keep-original ✅ original kept

Mutation-tested by replacing the guard condition with if False: the first case then fails on the deleted stego file, and reverting the mutation restores a byte-identical source.

Adjacent behaviour, deliberately not changed

Without --auto-rename, extracting into a directory that already holds a same-named file overwrites it silently (_resolve_conflict_path returns the same path and the writer opens it with 'wb'). That is a separate behaviour with its own decision to make. It is also what made this defect look like a "nothing was extracted" case when it was first hit.

reveal_file() removed the input whenever an extraction method reported success,
without checking that anything had reached the disk. Several shapes make a method
return True while writing zero files: an archive whose members are all directory
entries (zipping an empty folder produces one), an empty archive, or member names
that sanitize_path() rejects. The archive inside the stego file is then the only
copy left, and it was deleted.

The decision now rests on a before/after snapshot of the output directory (relative
path -> size and mtime in ns), not on the methods' return values. The input file is
excluded from the snapshot, as are the 7z_ / 7z_unzip_ temp directories
_extract_with_7zip creates, whose contents are intermediates rather than results --
if its cleanup ever fails, counting them would reintroduce the deletion this guard
exists to prevent.

A per-call counter incremented at the four places that write extracted files was
tried first and was wrong, which is worth recording: 7-Zip is invoked with
-o<output_dir> when auto-rename is off, so a subprocess writes the results and no
in-process counter sees them. That version passed the "nothing extracted" case but
broke the default for a normal reveal -- the original survived a successful
extraction. The snapshot covers subprocess writers as well, which is why it is the
mechanism.

When a method reports success and nothing appeared, the original is kept and the
reason is logged, rather than falling through to the generic "all methods failed"
list, which would be misleading -- a method did succeed.

Verified with scripts/test-reveal-keeps-original.py, covering both halves so that a
guard which simply stopped deleting anything could not pass: nothing extracted keeps
the original, a normal reveal still deletes it (delete_original_after_reveal defaults
to True), and --keep-original still keeps it. Mutation-tested by replacing the guard
condition with "if False": the first case then fails on the deleted stego file, and
removing the mutation restores a byte-identical source.

Not changed, deliberately: without --auto-rename, extracting into a directory that
already holds a same-named file overwrites it silently (_resolve_conflict_path
returns the same path and the writer opens it with 'wb'). That is a separate
behaviour with its own decision to make, and it is what made this defect look like a
"nothing was extracted" case when it was first hit.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cenglin123
cenglin123 merged commit eb1839e into main Sep 20, 2026
1 check 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

Development

Successfully merging this pull request may close these issues.

1 participant