fix(lemonade): validate downloaded archives - #143
Conversation
bb89ce9 to
cf10418
Compare
d7ad594 to
5c5b92a
Compare
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
5c5b92a to
a0b1f52
Compare
volen-silo
left a comment
There was a problem hiding this comment.
Solid, well-tested hardening — the core acquisition path is genuinely good (see the end of this review). Three things I would like addressed before merge, all in the same theme: the trust boundary ends one step earlier than the PR describes.
Verified locally: cargo test -p rocm-engine-lemonade --lib (48 passed), cargo clippy -p rocm-engine-lemonade --all-targets -- -D warnings clean. Both pinned digests confirmed byte-correct against the live upstream release artifacts (downloaded and hashed lemonade-embeddable-10.10.0-windows-x64.zip and -ubuntu-x64.tar.gz from the v10.10.0 release).
1. Verification and use are not atomic — the locks are released before extraction
engines/lemonade/src/lib.rs:986-1001
ensure_cached_archive acquires _process_guard and _archive_guard (lines 1410-1413), verifies the digest, and returns — dropping both guards. prepare_embeddable then calls extract_archive(&archive, &extract_root)? at line 1001 with no lock held, which re-opens the file from disk:
ensure_cached_archive(archive_url, &archive, embeddable_archive_sha256(), download_file)?;
...
extract_archive(&archive, &extract_root)?;So the bytes that are verified and the bytes that are extracted are two separate reads with an unlocked window between them. A co-resident writer to <root>/downloads/ can swap the archive in that window and the install proceeds on unverified content — which is precisely the property the file lock was introduced to guarantee.
The check-then-act coverage inside ensure_cached_archive_after_invalid is correct; it is the boundary at the return that leaks. Suggestion: have ensure_cached_archive return the ArchiveCacheLock and hold it across extract_archive, or re-verify immediately before extraction.
2. extract_archive — the one step that writes untrusted bytes — is outside the fix
engines/lemonade/src/lib.rs:1456-1467
This function is unchanged by the PR (it appears in the diff only as context). It shells out to tar and lets it write directly into destination:
let mut command = ProcessCommand::new("tar");
command.arg(if runtime_is_windows() { "-xf" } else { "-xzf" });
command.arg(archive).arg("-C").arg(destination);No entry-name validation, no --no-same-owner, and no post-extraction sweep. The new collect_embeddable_roots / copy_tree_entries checks only walk descendants of extract_root, so anything tar writes outside that root is never seen by them. Whatever traversal protection exists is whatever the locally installed tar happens to implement, which differs between GNU tar, the bsdtar that ships as Windows tar.exe, and busybox tar.
To be fair: this is only reachable if the digest pin is defeated, since ensure_cached_archive gates the call. So it is defense-in-depth, not a live vulnerability. But the root-cause section says the gap was "allowing archive-tree traversal outside the extracted tree", and the step that actually writes bytes to arbitrary paths is the one step left untouched. A containment sweep of extract_root after tar returns would close it cheaply.
Adjacent, pre-existing, worth a thought while you are here: ProcessCommand::new("tar") resolves the extractor from PATH. In a change about not trusting downloaded bytes, the binary that unpacks them is itself unpinned and unvalidated.
3. Symlink rejection is untested on Windows
engines/lemonade/src/lib.rs:3730 and :3753
embeddable_tree_rejects_external_file_symlink and embeddable_tree_rejects_external_directory_symlink are both #[cfg(unix)] and use std::os::unix::fs::symlink. Windows is this engine's primary platform — the Windows zip is the first-class artifact — so the entire is_symlink() rejection path added here (lines 1521, 1558, 1612) ships with no verification on the platform that matters most.
I checked the underlying semantics and the code is correct: Rust's Windows FileType::is_symlink() keys off the reparse point name-surrogate bit, so it returns true for junctions (IO_REPARSE_TAG_MOUNT_POINT) as well as symlinks. And a reparse point without that bit falls through to the !is_dir() && !is_file() arm and is rejected as a non-regular entry. Both good. But none of that is pinned down by a test.
Creating a symlink on Windows needs privilege, which I assume is why these are Unix-gated — but mklink /J creates a junction without elevation, so a junction-based test is feasible in CI where a symlink test is not, and the junction is exactly the case most likely to regress.
Non-blocking
- No test for the PR's headline case: a download that succeeds but returns bytes failing the digest.
poisoned_cached_archive_is_removed_and_redownloadedcovers a poisoned cache;failed_download_removes_private_partial_filecovers a download error. Nothing covers bad-content download. The code is correctly guarded (verify_sha256(&temporary_path, ...)?precedespublish_verified_archive), so this is a missing regression test rather than a bug — but a refactor that reordered those two lines would not be caught. - "Owner-private temporary file" holds on Unix only.
tempfilesets mode 0600 on Unix but sets no ACL orSECURITY_ATTRIBUTESon Windows, so the file inherits the parent directory's DACL — no better than plainFile::create. The error context at line 1443 says "failed to create private download beside {}". Worth wording for what is actually guaranteed per platform. concurrent_valid_archive_publication_succeeds_for_both_installers(3451) andconcurrent_invalid_archive_publication_fails_closed(3470) never exercise the locking path — they are a single-threadedfor _ in 0..2callingpublish_verified_archivedirectly, never reachinglock_archive_cacheor the mutex. They do genuinely test publish-conflict detection, so they are misnamed rather than useless; the real cross-process coverage isinterprocess_lock_serializes_poisoned_validation_and_recovery.EMBEDDABLE_ARCHIVE_CACHE_LOCKis a single global static, so it serializes installs of every archive andenv_rootin-process even though the per-archive file lock already provides correctness. Combined withfile.lock()having no timeout or try-lock, one install stuck inside its 15-minute download window blocks every other in-process install regardless of which archive it wants.copy_tree_entrieshas no depth bound while its siblingcollect_embeddable_rootscaps atdepth > 4(line 1517). Moot while the archive is digest-pinned; noting the asymmetry.
Worth calling out as good
publish_verified_archiveusespersist_noclobberand, on anAlreadyExistsconflict, re-verifies the file the winner published rather than assuming it is fine — fails closed on the race.- The temp file is created with
tempfile_in(parent), i.e. the destination's own directory, so the persist is a same-filesystem atomic rename rather than a copy. - The cache is re-verified on every use, not only on write.
- Mutex poisoning is recovered with
PoisonError::into_innerinstead of.unwrap(), and there is a test for it. interprocess_lock_serializes_poisoned_validation_and_recoveryspawns real child processes viacurrent_exe(), waits on stage markers, and asserts the second process did not acquire the lock while the first held it. That is a real concurrency test, and the#[cfg(test)]gating on the stage-marker and heartbeat instrumentation is clean — none of it compiles into the release binary.- Containment uses component-wise
Path::starts_withagainst roots thatcopy_treecanonicalizes on both the source and destination side — not the string-prefix bug this pattern usually has.
Review follow-up to the archive-validation change. Extraction shelled out to `tar`, resolved from `PATH`. A test that prepends a directory containing a fake `tar` to `PATH` shows the impostor is executed, so the binary unpacking untrusted bytes was itself unpinned; its traversal behaviour also varied between GNU tar, the bsdtar shipped as Windows `tar.exe`, and busybox tar. Extraction now runs in-process via `tar`/`flate2` and `zip`, validating every entry name against the extraction root and rejecting links, devices and other non-regular entries before anything is written. The cache lock was also released when verification returned, leaving an unlocked window in which the verified bytes and the extracted bytes were two separate reads. Verification now returns a guard that the caller holds across extraction and copy-out. Because that widens the hold, in-process exclusion is keyed per archive path instead of a single global mutex, so unrelated installs no longer queue behind a download window. Also: regression test for a download that succeeds with wrong bytes, a Windows junction test for the reparse-point rejection path, a depth bound on copy_tree_entries matching its sibling, per-platform wording on the temporary download's permissions, and renamed publish-conflict tests that never exercised locking. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
|
Thanks — the three blocking points were all correct, and one of them turned out to be sharper than the framing in the review. Addressed in 56e9ddf (plus 62334d3 recording the new dependencies). The reviewed commit a0b1f52 is untouched; everything below is a follow-up commit on top of it. 1. Verification and use are not atomicCorrect, and fixed at the boundary you identified. I confirmed the test is load-bearing rather than tautological: with the guard released before the return (the previous behaviour), 2.
|
|
CI is green on 62334d3, which closes the one verification gap I flagged above: |
|
Review note:
This PR reuses the same constant as a hard cap on the entire copied tree in The new tests only exercise a tree 1-2 levels deep ( Suggestion: decouple into two separate constants (a search-depth limit vs. a much larger — or unlimited — copy-depth limit), or verify the real official archive's actual nesting depth and add a test with realistic depth before merging. |
The embeddable-root search depth was reused as a hard cap on the copied tree, turning a give-up heuristic into a fatal limit that would reject a legitimate archive nested more than four levels deep. Containment is already enforced per entry, so copying now uses a separate recursion backstop set well above any plausible layout. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
|
Good catch — you were right that this was a materially different guarantee, and it's fixed in c64d184. Verified against the real archive first. The official The structural objection stands regardless of the current archive, which is why I fixed it rather than just recording the measurement. The cap bought nothing: containment in Now two constants:
New test Leaving this open for you to confirm — particularly the unmeasured Windows layout, if you have that artifact handy. |
Summary
Root cause
Downloaded and cached archives were trusted without integrity verification. Recursive embeddable-root discovery and copying also used path helpers that followed links, allowing archive-tree traversal outside the extracted tree.
Technical decisions
The change hardens the archive acquisition and extraction boundary without redesigning runtime installation as a transaction. Runtime replacement already occurs after archive preparation, while the identified trust gap was accepting unverified archive bytes and following unsafe archive-tree entries; a broader transaction redesign would add unrelated scope and operational risk.
Risk
Medium. The install path is intentionally stricter and will discard a corrupt cached archive or reject an archive tree containing links or special entries. Valid official archives retain the existing install behavior. Focused tests cover digest validation, cache recovery, concurrent publication, cross-process locking, temporary-file cleanup, containment, and symlink rejection.
Test plan
tests/e2e-cucumber/expectations.tomland found no matching stale xfail row to remove or narrow.proc_lifecyclefailures unrelated to this change; the focused tests above pass.