- 1
//! The release feed `self update` reads, and the version decision it drives. - 2
//! - 3
//! Two properties this module exists to guarantee: - 4
//! - 5
//! * **Ordering is semantic, not lexical.** Comparing version strings with - 6
//! `<=` puts `0.10.0` below `0.8.0`, so the first release past `0.9` - 7
//! reports "up to date" forever. Ordering goes through `semver::Version`. - 8
//! * **Every artifact carries a digest.** A feed without one cannot be - 9
//! verified after download, so it is rejected rather than trusted. - 10
- 11
use std::collections::BTreeMap; - 12
- 13
use semver::Version; - 14
- 15
/// Feed schema this build writes and understands. - 16
pub const SCHEMA: u32 = 2; - 17
- 18
/// One downloadable component of a release. - 19
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] - 20
pub struct Artifact { - 21
pub name: String, - 22
pub url: String, - 23
/// Lowercase hex SHA-256 of the bytes at `url`. - 24
pub sha256: String, - 25
#[serde(default)] - 26
pub required: bool, - 27
} - 28
- 29
/// The set of artifacts for one platform. - 30
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default, PartialEq, Eq)] - 31
pub struct Platform { - 32
#[serde(default)] - 33
pub components: Vec<Artifact>, - 34
} - 35
- 36
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] - 37
pub struct Feed { - 38
#[serde(default)] - 39
pub schema: u32, - 40
pub version: String, - 41
#[serde(default)] - 42
pub released_at: String, - 43
#[serde(default)] - 44
pub notes_url: Option<String>, - 45
#[serde(default)] - 46
pub platforms: BTreeMap<String, Platform>, - 47
} - 48
- 49
/// Why an update is or is not going to happen. - 50
#[derive(Debug, PartialEq, Eq)] - 51
pub enum Decision { - 52
/// The feed offers a strictly newer version. - 53
Upgrade { from: Version, to: Version }, - 54
/// Installed version is greater than or equal to the feed's. - 55
UpToDate { - 56
installed: Version, - 57
offered: Version, - 58
}, - 59
} - 60
- 61
/// Platform key used in the feed, e.g. `macos/aarch64`. - 62
pub fn platform_key() -> String { - 63
format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH) - 64
} - 65
- 66
/// Parse a version, tolerating a leading `v` as written in git tags. - 67
pub fn parse_version(raw: &str) -> Result<Version, String> { - 68
let cleaned = raw.trim().trim_start_matches(['v', 'V']); - 69
Version::parse(cleaned).map_err(|e| format!("version {raw:?} is not valid semver: {e}")) - 70
} - 71
- 72
/// The comparable form of a version. - 73
/// - 74
/// `semver::Version` orders build metadata as significant — it reports - 75
/// `0.8.0+build.7 > 0.8.0` — but semver §10 requires build metadata to be - 76
/// ignored when determining precedence. Left alone, a feed that stamps a - 77
/// build suffix would offer a perpetual "upgrade" to the version already - 78
/// installed. Precedence is therefore taken over `(major, minor, patch, - 79
/// pre)` with build metadata discarded. - 80
fn precedence(v: &Version) -> Version { - 81
Version { - 82
major: v.major, - 83
minor: v.minor, - 84
patch: v.patch, - 85
pre: v.pre.clone(), - 86
build: semver::BuildMetadata::EMPTY, - 87
} - 88
} - 89
- 90
impl Feed { - 91
pub fn parse(bytes: &[u8]) -> Result<Self, String> { - 92
let feed: Feed = serde_json::from_slice(bytes) - 93
.map_err(|e| format!("release feed is unreadable: {e}"))?; - 94
if feed.schema > SCHEMA { - 95
return Err(format!( - 96
"release feed schema {} is newer than this build understands ({SCHEMA}) — update manually", - 97
feed.schema - 98
)); - 99
} - 100
Ok(feed) - 101
} - 102
- 103
pub fn version(&self) -> Result<Version, String> { - 104
parse_version(&self.version) - 105
} - 106
- 107
/// Decide whether `installed` should move to this feed's version. - 108
/// Build metadata is ignored for ordering, per semver; prereleases - 109
/// order below their release, so `1.0.0-rc.1` never displaces `1.0.0`. - 110
pub fn decide(&self, installed: &str) -> Result<Decision, String> { - 111
let offered = self.version()?; - 112
let installed = parse_version(installed)?; - 113
if precedence(&offered) > precedence(&installed) { - 114
Ok(Decision::Upgrade { - 115
from: installed, - 116
to: offered, - 117
}) - 118
} else { - 119
Ok(Decision::UpToDate { installed, offered }) - 120
} - 121
} - 122
- 123
/// Artifacts for this platform, rejecting any entry that cannot be - 124
/// integrity-checked after download. - 125
pub fn artifacts_for(&self, key: &str) -> Result<Vec<Artifact>, String> { - 126
let platform = self.platforms.get(key).ok_or_else(|| { - 127
let known: Vec<&str> = self.platforms.keys().map(String::as_str).collect(); - 128
format!( - 129
"release {} has no artifacts for {key} (feed lists: {})", - 130
self.version, - 131
if known.is_empty() { - 132
"none".to_string() - 133
} else { - 134
known.join(", ") - 135
} - 136
) - 137
})?; - 138
if platform.components.is_empty() { - 139
return Err(format!( - 140
"release {} lists {key} with no components", - 141
self.version - 142
)); - 143
} - 144
if let Some(bad) = platform - 145
.components - 146
.iter() - 147
.find(|a| a.sha256.trim().is_empty()) - 148
{ - 149
return Err(format!( - 150
"artifact {:?} has no sha256 in the feed — refusing to install an unverifiable binary", - 151
bad.name - 152
)); - 153
} - 154
Ok(platform.components.clone()) - 155
} - 156
} - 157
- 158
#[cfg(test)] - 159
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 160
mod tests { - 161
use super::*; - 162
- 163
fn feed(version: &str) -> Feed { - 164
Feed { - 165
schema: SCHEMA, - 166
version: version.into(), - 167
released_at: String::new(), - 168
notes_url: None, - 169
platforms: BTreeMap::from([( - 170
"macos/aarch64".to_string(), - 171
Platform { - 172
components: vec![Artifact { - 173
name: "vak".into(), - 174
url: "https://example.invalid/vak".into(), - 175
sha256: "ab".repeat(32), - 176
required: true, - 177
}], - 178
}, - 179
)]), - 180
} - 181
} - 182
- 183
#[test] - 184
fn double_digit_minor_is_newer_than_single_digit() { - 185
// The defect this module exists to prevent: as strings, - 186
// "0.10.0" <= "0.8.0", which reported every later release as - 187
// already installed. - 188
assert!("0.10.0" <= "0.8.0", "string ordering really is this wrong"); - 189
assert_eq!( - 190
feed("0.10.0").decide("0.8.0").unwrap(), - 191
Decision::Upgrade { - 192
from: Version::parse("0.8.0").unwrap(), - 193
to: Version::parse("0.10.0").unwrap(), - 194
} - 195
); - 196
} - 197
- 198
#[test] - 199
fn equal_and_older_feeds_do_not_trigger_an_update() { - 200
assert!(matches!( - 201
feed("0.8.0").decide("0.8.0").unwrap(), - 202
Decision::UpToDate { .. } - 203
)); - 204
assert!(matches!( - 205
feed("0.7.9").decide("0.8.0").unwrap(), - 206
Decision::UpToDate { .. } - 207
)); - 208
} - 209
- 210
#[test] - 211
fn a_prerelease_never_displaces_the_matching_release() { - 212
assert!(matches!( - 213
feed("1.0.0-rc.1").decide("1.0.0").unwrap(), - 214
Decision::UpToDate { .. } - 215
)); - 216
assert!(matches!( - 217
feed("1.0.0").decide("1.0.0-rc.1").unwrap(), - 218
Decision::Upgrade { .. } - 219
)); - 220
assert!(matches!( - 221
feed("1.0.0-rc.2").decide("1.0.0-rc.1").unwrap(), - 222
Decision::Upgrade { .. } - 223
)); - 224
} - 225
- 226
#[test] - 227
fn build_metadata_is_accepted_and_ignored_for_ordering() { - 228
// semver::Version's own Ord disagrees with semver spec 10 here: - 229
// it reports 0.8.0+build.7 as greater than 0.8.0. Untreated, a - 230
// feed stamping a build suffix offers an endless upgrade to the - 231
// version already installed. - 232
assert!( - 233
Version::parse("0.8.0+build.7").unwrap() > Version::parse("0.8.0").unwrap(), - 234
"the library really does order build metadata" - 235
); - 236
assert!(matches!( - 237
feed("0.8.0+build.7").decide("0.8.0").unwrap(), - 238
Decision::UpToDate { .. } - 239
)); - 240
assert!(matches!( - 241
feed("0.8.0").decide("0.8.0+build.7").unwrap(), - 242
Decision::UpToDate { .. } - 243
)); - 244
// A real version bump still wins through the build suffix. - 245
assert!(matches!( - 246
feed("0.8.1+build.1").decide("0.8.0+build.9").unwrap(), - 247
Decision::Upgrade { .. } - 248
)); - 249
} - 250
- 251
#[test] - 252
fn leading_v_from_a_git_tag_is_tolerated() { - 253
assert_eq!( - 254
parse_version("v1.2.3").unwrap(), - 255
Version::parse("1.2.3").unwrap() - 256
); - 257
} - 258
- 259
#[test] - 260
fn an_artifact_without_a_digest_is_refused() { - 261
let mut f = feed("0.9.0"); - 262
f.platforms.get_mut("macos/aarch64").unwrap().components[0].sha256 = " ".into(); - 263
let err = f.artifacts_for("macos/aarch64").unwrap_err(); - 264
assert!(err.contains("unverifiable"), "got: {err}"); - 265
} - 266
- 267
#[test] - 268
fn a_missing_platform_names_what_the_feed_does_offer() { - 269
let err = feed("0.9.0").artifacts_for("linux/x86_64").unwrap_err(); - 270
assert!(err.contains("macos/aarch64"), "got: {err}"); - 271
} - 272
- 273
#[test] - 274
fn a_future_schema_is_refused_rather_than_misread() { - 275
let raw = br#"{"schema":99,"version":"9.9.9","platforms":{}}"#; - 276
assert!( - 277
Feed::parse(raw) - 278
.unwrap_err() - 279
.contains("newer than this build") - 280
); - 281
} - 282
} - 283
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.