Reuse staged file digests when preparing daemon installs (#53853)

## Why

Daemon installation rereads staged files to verify the executable and hash the normalized WinGet package, even though staging already computed their digests.

## What changed

Cache file digests while copying the package and reuse them for the staged executable comparison and final WinGet tree hash. Update cached paths when moving WinGet executables and refresh digests when rewriting manifests.

Continue hashing source packages and existing releases from disk so digest reuse does not bypass content validation.

## Testing

Extend the installation test to corrupt an existing release's `codex-package.json` and verify that reinstalling rejects it while preserving the current selection.

GitOrigin-RevId: 429da2ad8b2ee50f07ffb14b79281ab262a2d1a1
This commit is contained in:
Charlie Marsh
2026-10-14 03:26:47 +00:00
committed by copyberry
parent 2f05c0a71f
commit e342df7b66
3 changed files with 77 additions and 31 deletions
@@ -183,7 +183,7 @@ pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ExecutableIdentity {
digest: [u8; 32],
pub(crate) digest: [u8; 32],
// Distinguish release generations whose executable bytes are identical.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) path_digest: Option<[u8; 32]>,
@@ -2,6 +2,7 @@
//! existing selections; explicit replacements migrate to dedicated packages and stay pinned.
//! WinGet sources are verified in their original layout before staging a canonical daemon package.
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
@@ -231,18 +232,18 @@ async fn prepare_from_package(
let stage = tempfile::Builder::new()
.prefix(".staging.")
.tempdir_in(&releases)?;
let digest = package_tree(source, Some(stage.path()))?;
let mut staged_digests = BTreeMap::new();
let digest = package_tree(source, Some(stage.path()), &mut staged_digests)?;
let staged_exe = if cfg!(target_os = "macos") && stage.path().join("CodexCLI.app").is_dir() {
stage.path().join("CodexCLI.app/Contents/MacOS/codex")
Path::new("CodexCLI.app/Contents/MacOS/codex")
} else {
stage.path().join(source_entrypoint)
Path::new(source_entrypoint)
};
anyhow::ensure!(
package_tree(source, /*destination*/ None)? == digest
package_tree(source, /*destination*/ None, &mut BTreeMap::new())? == digest
&& std::fs::read(stage.path().join("codex-package.json"))? == manifest_bytes
&& managed_install::executable_identity(&staged_exe)
.await?
.same_contents(&running_identity),
&& staged_digests.get(staged_exe).map(blake3::Hash::as_bytes)
== Some(&running_identity.digest),
"the CLI package changed while preparing the daemon or differs from the running executable"
);
// Reverse the WinGet archive's root filenames only after verifying the copied
@@ -250,13 +251,17 @@ async fn prepare_from_package(
let digest = if winget {
std::fs::create_dir_all(stage.path().join("bin"))?;
#[cfg(windows)]
{
windows::publish_release(&staged_exe, &stage.path().join(entrypoint)).await?;
windows::publish_release(
&stage.path().join("codex-code-mode-host.exe"),
&stage.path().join("bin/codex-code-mode-host.exe"),
)
.await?;
for (from, to) in [
(staged_exe, Path::new(entrypoint)),
(
Path::new("codex-code-mode-host.exe"),
Path::new("bin/codex-code-mode-host.exe"),
),
] {
windows::publish_release(&stage.path().join(from), &stage.path().join(to)).await?;
if let Some(digest) = staged_digests.remove(from) {
staged_digests.insert(to.to_path_buf(), digest);
}
}
let voice_path = stage.path().join("codex-resources/voice/manifest.json");
if voice_path.try_exists()? {
@@ -269,14 +274,18 @@ async fn prepare_from_package(
.remove(source_entrypoint)
.context("WinGet voice manifest is missing the CLI checksum")?;
checksums.insert(entrypoint.to_string(), checksum);
std::fs::write(voice_path, serde_json::to_vec_pretty(&voice)?)?;
let bytes = serde_json::to_vec_pretty(&voice)?;
std::fs::write(voice_path, &bytes)?;
staged_digests.insert(
PathBuf::from("codex-resources/voice/manifest.json"),
blake3::hash(&bytes),
);
}
metadata["entrypoint"] = entrypoint.into();
std::fs::write(
stage.path().join("codex-package.json"),
serde_json::to_vec_pretty(&metadata)?,
)?;
package_tree(stage.path(), /*destination*/ None)?
let bytes = serde_json::to_vec_pretty(&metadata)?;
std::fs::write(stage.path().join("codex-package.json"), &bytes)?;
staged_digests.insert(PathBuf::from("codex-package.json"), blake3::hash(&bytes));
package_tree(stage.path(), /*destination*/ None, &mut staged_digests)?
} else {
digest
};
@@ -296,7 +305,7 @@ async fn prepare_from_package(
if release.try_exists()? {
anyhow::ensure!(
!release.symlink_metadata()?.file_type().is_symlink()
&& package_tree(&release, /*destination*/ None)? == digest,
&& package_tree(&release, /*destination*/ None, &mut BTreeMap::new())? == digest,
"an existing daemon release has different contents; refusing to overwrite it"
);
} else {
@@ -416,7 +425,13 @@ async fn prepare_from_package(
/// Hash the complete tree and optionally copy those same bytes. Relative file
/// links are materialized; escaping links and directory links are rejected.
fn package_tree(root: &Path, destination: Option<&Path>) -> Result<String> {
/// Only reuse digests for a private staging directory; source and release
/// validation must pass a fresh map.
fn package_tree(
root: &Path,
destination: Option<&Path>,
file_digests: &mut BTreeMap<PathBuf, blake3::Hash>,
) -> Result<String> {
let canonical_root = root.canonicalize()?;
let mut hasher = blake3::Hasher::new();
let mut paths = vec![root.to_path_buf()];
@@ -454,19 +469,28 @@ fn package_tree(root: &Path, destination: Option<&Path>) -> Result<String> {
paths.extend(entries);
} else {
anyhow::ensure!(metadata.is_file(), "package contains an unsupported file");
let bytes = std::fs::read(&path)?;
let digest = if let Some(digest) = file_digests.get(relative)
&& destination.is_none()
{
*digest
} else {
let bytes = std::fs::read(&path)?;
let digest = blake3::hash(&bytes);
if let Some(destination) = destination {
let target = destination.join(relative);
std::fs::write(&target, bytes)?;
std::fs::set_permissions(target, metadata.permissions())?;
}
file_digests.insert(relative.to_path_buf(), digest);
digest
};
hasher.update(b"file");
hasher.update(blake3::hash(&bytes).as_bytes());
hasher.update(digest.as_bytes());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
hasher.update(&(metadata.permissions().mode() & 0o777).to_le_bytes());
}
if let Some(destination) = destination {
let target = destination.join(relative);
std::fs::write(&target, bytes)?;
std::fs::set_permissions(target, metadata.permissions())?;
}
}
}
Ok(hasher.finalize().to_hex().to_string())
@@ -1,5 +1,7 @@
//! Covers complete-package staging, conservative selection, and legacy preservation.
#![cfg(unix)]
use std::collections::BTreeMap;
use super::InstallMode;
use super::prepare_from_package;
use super::validate_package;
@@ -86,7 +88,8 @@ async fn seeds_full_package() {
let target = super::platform_target().unwrap();
// Reinstall the seed's exact package after partially deleting an older copy.
let original = old;
let digest = super::package_tree(&original, /*destination*/ None).unwrap();
let digest =
super::package_tree(&original, /*destination*/ None, &mut BTreeMap::new()).unwrap();
let old = releases.join(format!("local-{digest}-{target}"));
let previous = releases.join(format!("0.151.0-{target}"));
package(&old, "0.152.0");
@@ -418,6 +421,25 @@ async fn explicit_selection_requires_unchanged_cli_and_pins_all_versions() {
assert!(daemon.running_backend(&settings).await.unwrap().is_none());
previous = selected;
}
// Reusing a release must still validate its current contents from disk.
std::fs::write(previous.join("codex-package.json"), b"corrupt manifest").unwrap();
let error = prepare_from_package(
&daemon(&home),
&settings,
InstallMode::Replace,
Some(&source),
&bin,
|_| Ok(true),
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("existing daemon release has different contents")
);
assert_eq!(root.join("current").canonicalize().unwrap(), previous);
}
}