Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/core/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ pub fn verify_bytes(data: &[u8], expected_sha256: &str) -> Result<bool> {
Ok(hash == expected_sha256)
}

pub fn verify_and_report(path: &Path, expected_sha256: &str, pkg_name: &str) -> Result<()> {
/// Verify `path` against `expected_sha256`, returning the observed digest on
/// success so callers can reuse it without hashing the file again.
pub fn verify_and_report(path: &Path, expected_sha256: &str, pkg_name: &str) -> Result<String> {
let actual = hash_file(path)?;
if actual != expected_sha256 {
bail!(
Expand All @@ -50,7 +52,7 @@ pub fn verify_and_report(path: &Path, expected_sha256: &str, pkg_name: &str) ->
This may indicate a corrupted download or tampered artifact."
);
}
Ok(())
Ok(actual)
}

#[cfg(test)]
Expand Down Expand Up @@ -89,4 +91,26 @@ mod tests {
fn verify_bytes_matches() {
assert!(verify_bytes(b"test", &compute_sha256(b"test")).unwrap());
}

#[test]
fn verify_and_report_returns_digest_on_success() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"report me").unwrap();
let expected = compute_sha256(b"report me");
let actual = verify_and_report(file.path(), &expected, "pkg").unwrap();
assert_eq!(actual, expected);
}

#[test]
fn verify_and_report_rejects_mismatch() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"report me").unwrap();
let err = verify_and_report(
file.path(),
"0000000000000000000000000000000000000000000000000000000000000000",
"pkg",
)
.unwrap_err();
assert!(err.to_string().contains("Integrity check failed"));
}
}
54 changes: 44 additions & 10 deletions src/install/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,41 @@ pub fn install_package(
let cache_file = cache_dir.join(format!("{cache_key}.bin"));
let cache_part = cache_dir.join(format!("{cache_key}.part"));

if !cache_file.exists() || options.force {
// Observed archive digest after integrity verification (or a fallback hash
// when the registry entry has no expected SHA).
let mut payload_sha256: Option<String> = None;
let mut need_download = !cache_file.exists() || options.force;

if !need_download {
if let Some(ref expected_sha) = artifact_sha256 {
match integrity::verify_and_report(&cache_file, expected_sha, &lock_key) {
Ok(digest) => {
if options.verbose {
println!("{} Using cached download", console::style("✓").green());
}
payload_sha256 = Some(digest);
}
Err(_) => {
// One-shot self-heal: drop the bad cache entry and re-download.
if options.verbose {
println!(
"{} Cached download failed integrity; re-downloading",
console::style("!").yellow()
);
}
let _ = std::fs::remove_file(&cache_file);
if cache_part.exists() {
let _ = std::fs::remove_file(&cache_part);
}
need_download = true;
}
}
} else if options.verbose {
println!("{} Using cached download", console::style("✓").green());
}
}

if need_download {
if options.verbose {
println!(
"{} Downloading {}@{}...",
Expand All @@ -213,27 +247,27 @@ pub fn install_package(
);
}
download::download_file(&artifact_url, &cache_part)?;
// Verify before promoting from .part to final
// Verify before promoting from .part to final; reuse digest as payload hash.
if let Some(ref expected_sha) = artifact_sha256 {
integrity::verify_and_report(&cache_part, expected_sha, &lock_key)?;
payload_sha256 = Some(integrity::verify_and_report(
&cache_part,
expected_sha,
&lock_key,
)?);
}
// Atomic promote
if cache_file.exists() {
std::fs::remove_file(&cache_file)?;
}
std::fs::rename(&cache_part, &cache_file)?;
} else if options.verbose {
println!("{} Using cached download", console::style("✓").green());
}

// Observed SHA-256 of the downloaded archive — distinct from the
// registry-declared `artifact_sha256` used for integrity verification.
let payload_sha256 = {
if payload_sha256.is_none() {
let bytes = std::fs::read(&cache_file).with_context(|| {
format!("Failed to read cached payload at {}", cache_file.display())
})?;
Some(integrity::compute_sha256(&bytes))
};
payload_sha256 = Some(integrity::compute_sha256(&bytes));
}

// 8. Extract to staging dir on same volume as install target
let parent_dir = install_dir.parent().unwrap_or(options.root);
Expand Down
222 changes: 221 additions & 1 deletion tests/install_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use ed25519_dalek::{Signer, SigningKey};
use numan_cli::core::nu_version::NuVersion;
use numan_cli::core::official_registry::RegistrySignature;
use numan_cli::core::package::{
Artifact, Package, PackageType, RegistryIndex, ScopedId, TargetArtifact, VersionEntry,
Artifact, EvidenceTier, Package, PackageType, RegistryIndex, ScopedId, TargetArtifact,
VersionEntry,
};
use numan_cli::core::platform::{Arch, Env, Os, Platform};
use numan_cli::install::transaction::{self, InstallOptions};
Expand Down Expand Up @@ -560,3 +561,222 @@ fn integration_snapshot_before_install() {
let snapshots = list_snapshots(&root).unwrap();
assert_eq!(snapshots.len(), 1, "Should have exactly one snapshot");
}

#[test]
fn integration_install_provisional_warns_only_on_new_install() {
const CHILD_MARKER: &str = "NUMAN_PROVISIONAL_INSTALL_TEST_CHILD";

if std::env::var_os(CHILD_MARKER).is_none() {
let output = Command::new(std::env::current_exe().unwrap())
.arg("integration_install_provisional_warns_only_on_new_install")
.arg("--exact")
.arg("--nocapture")
.env(CHILD_MARKER, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"Provisional-install child test failed.\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.split("===SECOND_INSTALL===").collect();
assert_eq!(
parts.len(),
2,
"expected delimiter between installs\nstdout:\n{stdout}"
);
assert!(
parts[0].contains(
"This package has not been lifecycle-tested. It passed integrity checks. (reason not recorded)"
),
"new provisional install must warn with fallback reason\nfirst half:\n{}",
parts[0]
);
assert!(
!parts[1].contains("lifecycle-tested"),
"already-installed no-op must not emit provisional warning\nsecond half:\n{}",
parts[1]
);
return;
}

let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let signing_key = setup_trusted_key(&root, "test");

let artifacts_dir = root.join("artifacts");
std::fs::create_dir_all(&artifacts_dir).unwrap();
let (zip_url, zip_sha) =
create_plugin_zip(&artifacts_dir, "nu_plugin_prov.exe", b"provisional plugin");

let package = Package {
id: ScopedId::new("test", "prov"),
description: "Provisional plugin".to_string(),
repo: "https://github.com/test/prov".to_string(),
package_type: PackageType::Plugin,
tags: vec![],
versions: vec![VersionEntry {
version: semver::Version::new(1, 0, 0),
nu_version: "*".to_string(),
verified_with: vec![],
artifact: Artifact {
kind: "binary".to_string(),
url: None,
sha256: None,
targets: {
let mut m = HashMap::new();
m.insert(
"x86_64-pc-windows-msvc".to_string(),
TargetArtifact {
url: zip_url,
sha256: zip_sha,
executable_path: "nu_plugin_prov.exe".to_string(),
},
);
m
},
archive_root: None,
include: None,
entry: None,
},
source: None,
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: Some(EvidenceTier::Provisional),
deferral_reason: None,
}],
};

create_signed_registry(&root, "test", vec![package], &signing_key);
std::fs::write(
root.join("config.toml"),
"[general]\ndefault_registry = \"test\"\n",
)
.unwrap();

let platform = Platform {
os: Os::Windows,
arch: Arch::X86_64,
env: Env::Msvc,
triple: "x86_64-pc-windows-msvc".to_string(),
};
let nu_version = NuVersion::parse("0.113.1").unwrap();
let options = InstallOptions {
root: &root,
platform: &platform,
nu_version: &nu_version,
force: false,
verbose: false,
registry_name: None,
snapshot_trigger: SnapshotTrigger::Install,
};

let first = transaction::install_package("test/prov", None, &options).unwrap();
assert!(first.installed);
assert!(!first.already_existed);

println!("===SECOND_INSTALL===");

let second = transaction::install_package("test/prov", None, &options).unwrap();
assert!(!second.installed);
assert!(second.already_existed);
}

#[test]
fn integration_install_self_heals_corrupted_cached_artifact() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let signing_key = setup_trusted_key(&root, "test");

let artifacts_dir = root.join("artifacts");
std::fs::create_dir_all(&artifacts_dir).unwrap();
let (zip_url, zip_sha) = create_plugin_zip(
&artifacts_dir,
"nu_plugin_cache.exe",
b"cache integrity plugin",
);

let package = Package {
id: ScopedId::new("test", "cache"),
description: "Cache integrity plugin".to_string(),
repo: "https://github.com/test/cache".to_string(),
package_type: PackageType::Plugin,
tags: vec![],
versions: vec![VersionEntry {
version: semver::Version::new(1, 0, 0),
nu_version: "*".to_string(),
verified_with: vec![],
artifact: Artifact {
kind: "binary".to_string(),
url: None,
sha256: None,
targets: {
let mut m = HashMap::new();
m.insert(
"x86_64-pc-windows-msvc".to_string(),
TargetArtifact {
url: zip_url,
sha256: zip_sha.clone(),
executable_path: "nu_plugin_cache.exe".to_string(),
},
);
m
},
archive_root: None,
include: None,
entry: None,
},
source: None,
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
};

create_signed_registry(&root, "test", vec![package], &signing_key);
std::fs::write(
root.join("config.toml"),
"[general]\ndefault_registry = \"test\"\n",
)
.unwrap();

// Plant a corrupted cache hit under the expected content-addressed key.
let cache_dir = root.join("cache/downloads");
std::fs::create_dir_all(&cache_dir).unwrap();
let cache_file = cache_dir.join(format!("{zip_sha}.bin"));
std::fs::write(&cache_file, b"not the real artifact").unwrap();

let platform = Platform {
os: Os::Windows,
arch: Arch::X86_64,
env: Env::Msvc,
triple: "x86_64-pc-windows-msvc".to_string(),
};
let nu_version = NuVersion::parse("0.113.1").unwrap();
let options = InstallOptions {
root: &root,
platform: &platform,
nu_version: &nu_version,
force: false,
verbose: false,
registry_name: None,
snapshot_trigger: SnapshotTrigger::Install,
};

let result = transaction::install_package("test/cache", None, &options)
.expect("corrupted cache should self-heal via one re-download");
assert!(result.installed);
assert!(root.join(&result.path).join("nu_plugin_cache.exe").exists());

let repaired = std::fs::read(&cache_file).unwrap();
assert_eq!(
numan_cli::core::integrity::compute_sha256(&repaired),
zip_sha,
"cache entry must be replaced with the verified artifact"
);
}
Loading