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
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ jobs:
if: matrix.cross
run: cross build --release --locked --target ${{ matrix.target }} -p varve

- name: Assert built binary reports the tag version (REQ-RELVER-001)
# The artifact-level twin of the version-guard job: run the freshly
# built binary and confirm `--version` == the tag. This is the exact
# oracle varve#38 asked for — a binary that mis-reports its own version
# (as v0.14.0 did) fails the release here. Native targets only; a
# cross-built binary cannot be executed on the runner.
if: ${{ !matrix.cross }}
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}"
REPORTED=$("target/${{ matrix.target }}/release/varve" --version | awk '{print $2}')
echo "tag=${VERSION#v} binary --version=$REPORTED"
if [ "${VERSION#v}" != "$REPORTED" ]; then
echo "::error::built binary reports '$REPORTED' but the release tag is $VERSION"
exit 1
fi

- name: Strip binary
if: ${{ !matrix.cross }}
run: strip "target/${{ matrix.target }}/release/varve" 2>/dev/null || true
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/varve", "crates/varve-core"]

[workspace.package]
version = "0.15.0"
version = "0.15.1"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/pulseengine/varve"
Expand Down
23 changes: 23 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1456,3 +1456,26 @@ artifacts:
fields:
priority: should
category: functional

- id: REQ-UPDATE-002
type: requirement
title: Self-update converges on artifact identity, not self-reported version
status: verified
release: v0.15.1
description: >
`varve self-update` (and `--check`) shall decide whether an update is
needed by comparing the VERIFIED latest binary's bytes against what is
already on disk, not by self-reported version strings alone. A binary
that mis-reports its own version (as v0.14.0 did — reporting 0.13.1) must
NOT loop forever: version-string comparison stays "newer" and every run
re-installs identical bytes (varve#38). Deciding on digest identity makes
a stale version string degrade to a no-op. `--check` fetches and verifies
the candidate (needing the trust root) so it reports a genuinely-verified
update, not a phantom one.
tags: [core, integrity]
links:
- type: traces-to
target: REQ-UPDATE-001
fields:
priority: must
category: functional
54 changes: 54 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,8 @@ artifacts:
steps:
# The guard job exists and asserts the agreement before any build.
- run: grep -q "does not match Cargo.toml version" .github/workflows/release.yml
# Artifact-level twin (varve#38): the built binary's --version is asserted == tag.
- run: grep -q "built binary reports" .github/workflows/release.yml
links:
- type: verifies
target: REQ-RELVER-001
Expand Down Expand Up @@ -580,3 +582,55 @@ artifacts:
target: REQ-CRATE-001
- type: verifies
target: REQ-RELVER-001

- id: VER-UPDATE-002
type: verification
title: A wrong version string degrades to a no-op instead of an update loop
status: verified
release: v0.15.1
fields:
method: automated-test
steps:
# The pure identity anchor.
- run: cargo test -p varve-core a_wrong_version_string_does_not_force_an_update_when_the_bytes_match
# The behavioral claim, end-to-end through a release-API double: a
# mis-reported version resolves to AlreadyCurrent (no loop), differing
# bytes to Available, and an impostor is refused before being offered.
- run: cargo test -p varve-core --test selfupdate_double a_mis_reported_version_converges_on_artifact_identity_not_a_loop
- run: cargo test -p varve-core --test selfupdate_double resolve_update_verifies_before_it_offers_an_impostor
links:
- type: verifies
target: REQ-UPDATE-002

- id: VER-REVIEW-v0.15.1
type: verification
title: v0.15.1 self-update fix independently reviewed — dissent raised, resolved, re-confirmed
status: verified
release: v0.15.1
tags: [independence, review]
fields:
method: review
baseline: >
Two independent clean-room reviews (fresh context, refute-framed). The
FIRST found the implementation sound (all five refutations failed — the
loop breaks, trust is preserved, digests are sound, --check's root
requirement is disclosed, the release version-assertion is correct) but
DISSENTED on one substantive ground: REQ-UPDATE-002 is a handler-level
requirement, yet its only evidence was a pure unit test of the
`already_current` helper — the handler behaviour (loop-break,
verify-before-compare, --check no-op) had zero coverage. Resolved before
release: the decision was lifted into a core `resolve_update`
(UpToDate/AlreadyCurrent/Available), the handler reduced to a thin
wrapper, and behavioural tests added through the real release-API mock
server (signed archive, real ed25519 verify) — a mis-reported version
with identical on-disk bytes resolves to AlreadyCurrent (loop
terminates), and an impostor is refused before ever being offered. A
SECOND independent review re-ran the suites (6 self-update, 128 lib, 34
CLI) and confirmed VERDICT pass, DISSENT CLOSED — the identity decision
is genuinely exercised end-to-end. Residual, non-blocking: the handler's
printed-message glue and --check install-gate have no test at any level
(cli.rs carries no self-update case); the identity decision they wrap is
covered. Tracked as a follow-up hardening, not a release blocker.
links:
- type: verifies
target: REQ-UPDATE-002
111 changes: 101 additions & 10 deletions crates/varve-core/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ pub fn is_newer(candidate: &str, current: &str) -> bool {
}
}

/// Whether the running binary is already the latest release's binary, decided
/// on ARTIFACT IDENTITY rather than self-reported version strings (varve#38).
/// A binary that mis-reports its own version (as v0.14.0 did) would otherwise
/// loop forever: `is_newer` stays true, every check re-installs the same bytes.
/// Comparing digests makes a stale version string degrade to a no-op.
pub fn already_current(running_binary: &[u8], latest_binary: &[u8]) -> bool {
crate::store::manifest_digest(running_binary) == crate::store::manifest_digest(latest_binary)
}

/// Ask the release API for the latest tag and locate this platform's assets.
/// `api_latest_url` is the GitHub "latest release" endpoint (or a mirror /
/// test double — the URL changes availability, never acceptance).
Expand Down Expand Up @@ -144,14 +153,14 @@ pub fn extract_tool_from_targz(bytes: &[u8], tool: &str) -> Result<Vec<u8>, Upda
Err(UpdateError::NoBinaryInArchive)
}

/// Execute an update plan: download, VERIFY with the running binary's trust
/// root, extract, and atomically install at `dest`. Returns the verified
/// archive digest.
pub fn perform(
/// Download and verify the successor binary WITHOUT installing it — the
/// running varve verifies its successor against the trust root. Returns the
/// verified binary bytes and the archive digest. Splitting this from the write
/// lets the caller decide on artifact identity before touching disk (varve#38).
pub fn fetch_verified_binary(
plan: &UpdatePlan,
root_public_key: &[u8],
dest: &std::path::Path,
) -> Result<String, UpdateError> {
) -> Result<(Vec<u8>, String), UpdateError> {
let agent = ureq::Agent::new_with_defaults();
let fetch = |url: &str| -> Result<Vec<u8>, UpdateError> {
agent
Expand All @@ -167,25 +176,87 @@ pub fn perform(
};
let envelope = fetch(&plan.envelope_url)?;
let archive = fetch(&plan.archive_url)?;

// The trust decision: the running varve verifies its successor.
let digest = verify_release_file(&plan.archive_name, &archive, &envelope, root_public_key)?;

let binary = extract_tool_from_targz(&archive, "varve")?;
Ok((binary, digest))
}

/// Atomically install already-verified successor bytes at `dest`.
pub fn install_binary(binary: &[u8], dest: &std::path::Path) -> Result<(), UpdateError> {
let io = |path: &std::path::Path, source: std::io::Error| UpdateError::Io {
path: path.display().to_string(),
source,
};
// Atomic on the same filesystem: write beside dest, then rename over it.
let tmp = dest.with_extension("varve-update-tmp");
std::fs::write(&tmp, &binary).map_err(|e| io(&tmp, e))?;
std::fs::write(&tmp, binary).map_err(|e| io(&tmp, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| io(&tmp, e))?;
}
std::fs::rename(&tmp, dest).map_err(|e| io(dest, e))?;
Ok(())
}

/// The self-update decision, resolved on ARTIFACT IDENTITY (varve#38).
#[derive(Debug)]
pub enum UpdateDecision {
/// The API's latest is not newer by version — nothing fetched.
UpToDate,
/// The version string says newer, but the verified latest binary is
/// byte-identical to what is on disk. A no-op — this is what breaks the
/// mis-reported-version loop.
AlreadyCurrent { latest: String },
/// A genuine, verified update is available: the plan, the verified binary
/// bytes (ready to install), and the archive digest.
Available {
plan: UpdatePlan,
binary: Vec<u8>,
digest: String,
},
}

/// Resolve whether an update is needed, deciding on artifact identity rather
/// than self-reported version strings (varve#38). `on_disk` is the current
/// binary's bytes (None if the destination does not yet exist). Fetches and
/// VERIFIES the candidate against the trust root before comparing or offering
/// it, so a reported "available" is always a genuinely-verified update.
pub fn resolve_update(
api_latest_url: &str,
current_version: &str,
platform: &str,
on_disk: Option<&[u8]>,
root_public_key: &[u8],
) -> Result<UpdateDecision, UpdateError> {
let Some(plan) = check_latest(api_latest_url, current_version, platform)? else {
return Ok(UpdateDecision::UpToDate);
};
let (binary, digest) = fetch_verified_binary(&plan, root_public_key)?;
if let Some(current) = on_disk
&& already_current(current, &binary)
{
return Ok(UpdateDecision::AlreadyCurrent {
latest: plan.latest,
});
}
Ok(UpdateDecision::Available {
plan,
binary,
digest,
})
}

/// Download, verify against the trust root, extract, and atomically install at
/// `dest`. Returns the verified archive digest.
pub fn perform(
plan: &UpdatePlan,
root_public_key: &[u8],
dest: &std::path::Path,
) -> Result<String, UpdateError> {
let (binary, digest) = fetch_verified_binary(plan, root_public_key)?;
install_binary(&binary, dest)?;
Ok(digest)
}

Expand All @@ -206,6 +277,26 @@ mod tests {
assert!(!is_newer("0.8.0.1", "0.7.0"));
}

// rivet: verifies REQ-UPDATE-002
#[test]
fn a_wrong_version_string_does_not_force_an_update_when_the_bytes_match() {
// The varve#38 loop: a binary reporting "0.13.1" that is actually the
// latest release. Version strings alone say "update forever"; artifact
// identity says "already current" and the loop terminates.
let running = b"the-genuine-latest-binary";
let latest = b"the-genuine-latest-binary";
assert!(
is_newer("v0.14.0", "0.13.1"),
"version strings alone would loop"
);
assert!(
already_current(running, latest),
"identical verified bytes must read as already-current regardless of version"
);
// A genuine update has different bytes.
assert!(!already_current(running, b"a-newer-binary"));
}

// rivet: verifies REQ-UPDATE-001
#[test]
fn the_binary_is_extracted_from_a_release_shaped_tarball() {
Expand Down
52 changes: 51 additions & 1 deletion crates/varve-core/tests/selfupdate_double.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::Arc;

use varve_core::update::{check_latest, perform};
use varve_core::update::{UpdateDecision, check_latest, perform, resolve_update};

/// Serve a fake "latest release" API + asset downloads.
fn serve(tag: &str, assets: BTreeMap<String, Vec<u8>>) -> String {
Expand Down Expand Up @@ -116,6 +116,56 @@ fn the_running_binary_verifies_and_installs_its_successor() {
assert_eq!(std::fs::read(&dest).unwrap(), b"new-varve-bytes");
}

// rivet: verifies REQ-UPDATE-002
#[test]
fn a_mis_reported_version_converges_on_artifact_identity_not_a_loop() {
// The varve#38 loop: the running binary reports "0.13.1" but IS the latest
// release bytes. Version strings alone say "update forever"; resolving on
// artifact identity says AlreadyCurrent — a no-op, so the loop terminates.
let release_binary = b"the-genuine-v99-binary";
let (api, pk, _) = release_fixture("v99.0.0", release_binary, true);
let platform = varve_core::host_platform();

// On-disk bytes are byte-identical to the (verified) latest release.
let decision = resolve_update(&api, "0.13.1", &platform, Some(release_binary), &pk).unwrap();
assert!(
matches!(decision, UpdateDecision::AlreadyCurrent { .. }),
"identical verified bytes must resolve to AlreadyCurrent, got {decision:?}"
);

// Different on-disk bytes → a genuine, verified update is available.
let decision = resolve_update(&api, "0.13.1", &platform, Some(b"stale-bytes"), &pk).unwrap();
assert!(
matches!(decision, UpdateDecision::Available { .. }),
"differing bytes must resolve to Available, got {decision:?}"
);

// A version that is not newer never fetches — UpToDate, root untouched path.
let decision = resolve_update(&api, "100.0.0", &platform, Some(b"x"), &pk).unwrap();
assert!(matches!(decision, UpdateDecision::UpToDate));
}

// rivet: verifies REQ-UPDATE-002
#[test]
fn resolve_update_verifies_before_it_offers_an_impostor() {
// A release signed by an impostor must not surface as Available even though
// its version is newer — resolve_update verifies before comparing/offering.
let (api, _real_pk, _) = release_fixture("v99.0.0", b"evil-bytes", true);
let (_, other_pk) = varve_core::generate_root_keypair();
let err = resolve_update(
&api,
"0.13.1",
&varve_core::host_platform(),
Some(b"current"),
&other_pk,
)
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("signature"),
"{err}"
);
}

// rivet: verifies REQ-UPDATE-001
#[test]
fn an_older_or_equal_release_is_a_no_op() {
Expand Down
Loading
Loading