1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
|
use std::{fs, path::Path, path::PathBuf};
use mingling::{
Grouped, LazyRes, RenderResult, Routable,
macros::{chain, command, metadata, renderer, routeify},
metadata::Description,
};
use sha2::{Digest, Sha256};
use crate::{Next, config::ResMlingConfig, eprintln_cargo, println_cargo};
/// Config key holding the base URL that hosts the mling release packages.
const CONFIG_KEY_UPDATE_URL: &str = "update-url";
/// Default update source, used when the config key is unset.
const DEFAULT_UPDATE_URL: &str = "https://mingling-rs.github.io/mingling/dist";
/// Name of the staged update package inside `{data_dir}/mingling`.
const UPDATE_FILE_NAME: &str = "update.tar.gz";
/// Records the sha256 of the last applied update, written by the wrapper.
const LAST_UPDATE_FILE_NAME: &str = "last-update.sha256";
/// The resolved download task: check the remote checksum and stage the package.
#[derive(Debug, Default, Grouped)]
pub struct StateUpdateDownload {
pub base_url: String,
pub update_path: PathBuf,
}
/// The latest package is already installed.
#[derive(Debug, Default, Grouped)]
pub struct ResultUpdateUpToDate;
/// The latest package was downloaded, verified, and staged for the wrapper.
#[derive(Debug, Default, Grouped)]
pub struct ResultUpdateStaged {
pub update_path: PathBuf,
}
/// Errors produced by the download pipeline.
#[derive(Debug, Grouped)]
pub enum UpdateError {
/// The data directory could not be determined.
NoDataDirectory,
/// The configured update URL is not a valid `http(s)://` URL.
InvalidUrl(String),
/// A network request failed, or the remote responded with an error.
Network(String),
/// The downloaded package failed its sha256 verification.
ChecksumMismatch(String),
/// Writing the staged update package failed.
Io(String),
}
#[metadata(EntryUpdate)]
pub fn desc_update() -> Description {
"Update mling to the latest version".into()
}
#[command(routeify)]
pub fn update(config: &mut LazyRes<ResMlingConfig>) -> Next {
let config = config.get_ref();
let source = config.get_or(CONFIG_KEY_UPDATE_URL, DEFAULT_UPDATE_URL);
let Some(update_path) = update_package_path() else {
return UpdateError::NoDataDirectory.to_chain();
};
if !is_http_url(source) {
return UpdateError::InvalidUrl(source.to_string()).to_chain();
}
StateUpdateDownload {
base_url: source.to_string(),
update_path,
}
.to_chain()
}
/// Check the remote checksum against the installed version; if they differ,
/// download the package, verify its checksum, and stage it at
/// `{data_dir}/mingling/update.tar.gz`.
#[chain(routeify)]
pub async fn handle_state_update_download(state: StateUpdateDownload) -> Next {
match check_and_fetch(&state.base_url, &state.update_path).await {
Ok(FetchOutcome::UpToDate) => ResultUpdateUpToDate.to_chain(),
Ok(FetchOutcome::Staged) => ResultUpdateStaged {
update_path: state.update_path,
}
.to_chain(),
Err(e) => e.to_chain(),
}
}
#[renderer]
pub fn render_result_update_up_to_date(_: ResultUpdateUpToDate) -> RenderResult {
let mut result = RenderResult::new();
println_cargo!(result, "mling is already up to date");
result
}
#[renderer]
pub fn render_result_update_staged(r: ResultUpdateStaged) -> RenderResult {
let mut result = RenderResult::new();
println_cargo!(result, "Downloaded: {}", r.update_path.display());
println_cargo!(result, "Run `mling` again to apply the update");
result
}
#[renderer]
pub fn render_error_update(err: UpdateError) -> RenderResult {
let mut result = RenderResult::new();
match err {
UpdateError::NoDataDirectory => {
eprintln_cargo!(result, "failed to determine the data directory");
}
UpdateError::InvalidUrl(source) => {
eprintln_cargo!(
result,
"invalid update URL `{}`, expected an `http(s)://` URL such as `https://mingling-rs.github.io/mingling/dist`",
source
);
}
UpdateError::Network(msg) | UpdateError::ChecksumMismatch(msg) | UpdateError::Io(msg) => {
eprintln_cargo!(result, "{}", msg);
}
}
result
}
/// `{data_dir}/mingling/update.tar.gz`, where the wrapper looks for staged updates.
pub fn update_package_path() -> Option<PathBuf> {
dirs::data_dir().map(|dir| dir.join("mingling").join(UPDATE_FILE_NAME))
}
/// `{data_dir}/mingling/last-update.sha256`, the checksum of the last applied update.
fn last_update_checksum_path() -> Option<PathBuf> {
dirs::data_dir().map(|dir| dir.join("mingling").join(LAST_UPDATE_FILE_NAME))
}
fn is_http_url(source: &str) -> bool {
let source = source.trim();
source.starts_with("http://") || source.starts_with("https://")
}
/// The platform suffix used by the package names (`mling-{os}.tar.gz`).
fn update_os_name() -> &'static str {
if cfg!(windows) {
"win"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"mac"
} else {
"unknown"
}
}
enum FetchOutcome {
UpToDate,
Staged,
}
/// Fetch `mling-{os}.tar.gz.sha256`, skip the download when the installed
/// version already matches, then download and verify the package before
/// staging it.
async fn check_and_fetch(base_url: &str, update_path: &Path) -> Result<FetchOutcome, UpdateError> {
let os = update_os_name();
let base = base_url.trim_end_matches('/');
let checksum_url = format!("{base}/mling-{os}.tar.gz.sha256");
let package_url = format!("{base}/mling-{os}.tar.gz");
let client = reqwest::Client::builder()
.user_agent(format!("mling-updater/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}")))?;
// 1. Fetch the remote checksum first.
let response = client.get(&checksum_url).send().await.map_err(|e| {
UpdateError::Network(format!(
"failed to fetch checksum from `{checksum_url}`: {e}"
))
})?;
if !response.status().is_success() {
return Err(UpdateError::Network(format!(
"failed to fetch checksum from `{checksum_url}`: HTTP {}",
response.status()
)));
}
let checksum_text = response
.text()
.await
.map_err(|e| UpdateError::Network(format!("failed to read checksum: {e}")))?;
let remote_sha = parse_sha256(&checksum_text).ok_or_else(|| {
UpdateError::Network(format!("invalid checksum file at `{checksum_url}`"))
})?;
// 2. Skip the download when the installed version already matches.
if let Some(local_sha) = read_last_update_checksum()
&& local_sha == remote_sha
{
return Ok(FetchOutcome::UpToDate);
}
// 3. Download the package.
let response =
client.get(&package_url).send().await.map_err(|e| {
UpdateError::Network(format!("failed to download `{package_url}`: {e}"))
})?;
if !response.status().is_success() {
return Err(UpdateError::Network(format!(
"failed to download `{package_url}`: HTTP {}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| UpdateError::Network(format!("failed to read package body: {e}")))?;
// 4. Verify the package before staging it.
let actual_sha = sha256_hex(&bytes);
if actual_sha != remote_sha {
return Err(UpdateError::ChecksumMismatch(format!(
"checksum mismatch for `{package_url}`: expected {remote_sha}, got {actual_sha}"
)));
}
// 5. Stage it for the wrapper.
write_update_package(&bytes, update_path)?;
Ok(FetchOutcome::Staged)
}
/// Parse the sha256 hex digest from a `sha256sum`-style line (`<hash> <file>`).
fn parse_sha256(line: &str) -> Option<String> {
let hash = line.split_whitespace().next()?;
(hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
}
/// The checksum of the last update applied by the wrapper, if recorded.
fn read_last_update_checksum() -> Option<String> {
let path = last_update_checksum_path()?;
let content = fs::read_to_string(path).ok()?;
let sha = content.trim();
(!sha.is_empty()).then(|| sha.to_string())
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
/// Stage the update package at the wrapper's well-known location. The bytes are
/// written to a temporary file first so a failed download never corrupts a
/// previously staged update.
fn write_update_package(package: &[u8], update_path: &Path) -> Result<(), UpdateError> {
let parent = update_path.parent().ok_or_else(|| {
UpdateError::Io(format!("no parent directory for {}", update_path.display()))
})?;
fs::create_dir_all(parent).map_err(|e| UpdateError::Io(e.to_string()))?;
let tmp_path = parent.join("update.tar.gz.tmp");
fs::write(&tmp_path, package).map_err(|e| UpdateError::Io(e.to_string()))?;
if update_path.exists() {
fs::remove_file(update_path).map_err(|e| UpdateError::Io(e.to_string()))?;
}
fs::rename(&tmp_path, update_path).map_err(|e| UpdateError::Io(e.to_string()))
}
|