| 107 |
107 |
|
}
|
| 108 |
108 |
|
|
| 109 |
109 |
|
impl RecipeCtx {
|
|
110 |
+ |
/// Versions of `name` already on crates.io. A network failure yields an
|
|
111 |
+ |
/// empty list: preflight then cannot claim a version is a duplicate, and
|
|
112 |
+ |
/// `cargo publish` still refuses one, so the check degrades to advisory
|
|
113 |
+ |
/// rather than blocking a release on registry availability.
|
|
114 |
+ |
fn published_versions(&self, name: &str) -> Vec<String> {
|
|
115 |
+ |
let url = format!("https://crates.io/api/v1/crates/{name}");
|
|
116 |
+ |
let Ok(out) = std::process::Command::new("curl")
|
|
117 |
+ |
.args(["-sS", "--max-time", "15", "-H", "User-Agent: bento-preflight", &url])
|
|
118 |
+ |
.output()
|
|
119 |
+ |
else {
|
|
120 |
+ |
return Vec::new();
|
|
121 |
+ |
};
|
|
122 |
+ |
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
|
123 |
+ |
return Vec::new();
|
|
124 |
+ |
};
|
|
125 |
+ |
v.get("versions")
|
|
126 |
+ |
.and_then(|x| x.as_array())
|
|
127 |
+ |
.map(|a| {
|
|
128 |
+ |
a.iter()
|
|
129 |
+ |
.filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
|
|
130 |
+ |
.collect()
|
|
131 |
+ |
})
|
|
132 |
+ |
.unwrap_or_default()
|
|
133 |
+ |
}
|
|
134 |
+ |
|
| 110 |
135 |
|
#[allow(clippy::too_many_arguments)]
|
| 111 |
136 |
|
pub fn new(
|
| 112 |
137 |
|
app: AppId,
|
| 351 |
376 |
|
Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE))
|
| 352 |
377 |
|
}
|
| 353 |
378 |
|
|
|
379 |
+ |
|
|
380 |
+ |
/// A crate's publish-relevant metadata, read from `cargo metadata`.
|
|
381 |
+ |
#[derive(Debug, Clone)]
|
|
382 |
+ |
pub struct CrateMeta {
|
|
383 |
+ |
pub name: String,
|
|
384 |
+ |
pub version: String,
|
|
385 |
+ |
pub repository: Option<String>,
|
|
386 |
+ |
pub description: Option<String>,
|
|
387 |
+ |
pub licensed: bool,
|
|
388 |
+ |
}
|
|
389 |
+ |
|
|
390 |
+ |
/// Parse the fields that matter for publishing out of `cargo metadata` JSON.
|
|
391 |
+ |
pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
|
|
392 |
+ |
let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?;
|
|
393 |
+ |
let p = v
|
|
394 |
+ |
.get("packages")
|
|
395 |
+ |
.and_then(|p| p.as_array())
|
|
396 |
+ |
.and_then(|a| a.first())
|
|
397 |
+ |
.context("cargo metadata reported no package")?;
|
|
398 |
+ |
let str_field = |k: &str| {
|
|
399 |
+ |
p.get(k).and_then(|x| x.as_str()).filter(|s| !s.is_empty()).map(str::to_string)
|
|
400 |
+ |
};
|
|
401 |
+ |
Ok(CrateMeta {
|
|
402 |
+ |
name: str_field("name").context("package has no name")?,
|
|
403 |
+ |
version: str_field("version").context("package has no version")?,
|
|
404 |
+ |
repository: str_field("repository"),
|
|
405 |
+ |
description: str_field("description"),
|
|
406 |
+ |
licensed: str_field("license").is_some() || str_field("license_file").is_some(),
|
|
407 |
+ |
})
|
|
408 |
+ |
}
|
|
409 |
+ |
|
|
410 |
+ |
/// Everything wrong with a crate's metadata, as messages. Empty means publishable.
|
|
411 |
+ |
///
|
|
412 |
+ |
/// Checks only what crates.io records permanently. A published version cannot
|
|
413 |
+ |
/// be edited, only yanked, and yanking does not correct a wrong URL — so these
|
|
414 |
+ |
/// are the last moment any of it can be fixed.
|
|
415 |
+ |
pub fn crate_publish_problems(meta: &CrateMeta, repo_clonable: bool, published: &[String]) -> Vec<String> {
|
|
416 |
+ |
let mut out = Vec::new();
|
|
417 |
+ |
match &meta.repository {
|
|
418 |
+ |
None => out.push(
|
|
419 |
+ |
"no `repository` field: the crates.io page will show no source link, permanently"
|
|
420 |
+ |
.to_string(),
|
|
421 |
+ |
),
|
|
422 |
+ |
Some(url) if !repo_clonable => out.push(format!(
|
|
423 |
+ |
"`repository` is not publicly clonable: {url} \
|
|
424 |
+ |
(wrong URL, or the repo is private)"
|
|
425 |
+ |
)),
|
|
426 |
+ |
Some(_) => {}
|
|
427 |
+ |
}
|
|
428 |
+ |
if meta.description.is_none() {
|
|
429 |
+ |
out.push("no `description`: crates.io requires one".to_string());
|
|
430 |
+ |
}
|
|
431 |
+ |
if !meta.licensed {
|
|
432 |
+ |
out.push("no `license` or `license-file`".to_string());
|
|
433 |
+ |
}
|
|
434 |
+ |
if published.iter().any(|v| v == &meta.version) {
|
|
435 |
+ |
out.push(format!("version {} is already published; bump it", meta.version));
|
|
436 |
+ |
}
|
|
437 |
+ |
out
|
|
438 |
+ |
}
|
|
439 |
+ |
|
| 354 |
440 |
|
/// Read the app's version from its checkout on the daemon host. With
|
| 355 |
441 |
|
/// `version_path` set (topology `version_path`), read exactly that file — a
|
| 356 |
442 |
|
/// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
|
| 510 |
596 |
|
engine.register_fn("repo", move || -> String { ctx.repo.clone() });
|
| 511 |
597 |
|
}
|
| 512 |
598 |
|
|
|
599 |
+ |
// --- crate_preflight() -> string: verify this crate is safe to publish,
|
|
600 |
+ |
// or abort the run. Everything it checks is immutable once published:
|
|
601 |
+ |
// crates.io versions can be yanked but never edited, so a wrong
|
|
602 |
+ |
// repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
|
|
603 |
+ |
{
|
|
604 |
+ |
let ctx = ctx.clone();
|
|
605 |
+ |
engine.register_fn("crate_preflight", move || -> Result<String, Box<EvalAltResult>> {
|
|
606 |
+ |
let repo = expand_tilde(&ctx.repo);
|
|
607 |
+ |
|
|
608 |
+ |
let out = std::process::Command::new("cargo")
|
|
609 |
+ |
.args(["metadata", "--no-deps", "--format-version", "1"])
|
|
610 |
+ |
.current_dir(&repo)
|
|
611 |
+ |
.output()
|
|
612 |
+ |
.map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
|
|
613 |
+ |
if !out.status.success() {
|
|
614 |
+ |
return Err(format!(
|
|
615 |
+ |
"cargo metadata failed in {}: {}",
|
|
616 |
+ |
repo.display(),
|
|
617 |
+ |
String::from_utf8_lossy(&out.stderr).trim()
|
|
618 |
+ |
)
|
|
619 |
+ |
.into());
|
|
620 |
+ |
}
|
|
621 |
+ |
let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
|
|
622 |
+ |
.map_err(|e| e.to_string())?;
|
|
623 |
+ |
|
|
624 |
+ |
// The real question is not whether a page renders but whether a
|
|
625 |
+ |
// stranger with no credentials can fetch the source, so ask git.
|
|
626 |
+ |
let clonable = meta.repository.as_ref().is_some_and(|url| {
|
|
627 |
+ |
std::process::Command::new("git")
|
|
628 |
+ |
.args(["ls-remote", url])
|
|
629 |
+ |
.env("GIT_TERMINAL_PROMPT", "0")
|
|
630 |
+ |
.output()
|
|
631 |
+ |
.map(|o| o.status.success())
|
|
632 |
+ |
.unwrap_or(false)
|
|
633 |
+ |
});
|
|
634 |
+ |
|
|
635 |
+ |
let published = ctx.published_versions(&meta.name);
|
|
636 |
+ |
let problems = crate_publish_problems(&meta, clonable, &published);
|
|
637 |
+ |
if !problems.is_empty() {
|
|
638 |
+ |
return Err(format!(
|
|
639 |
+ |
"{} {} is not safe to publish:\n - {}",
|
|
640 |
+ |
meta.name,
|
|
641 |
+ |
meta.version,
|
|
642 |
+ |
problems.join("\n - ")
|
|
643 |
+ |
)
|
|
644 |
+ |
.into());
|
|
645 |
+ |
}
|
|
646 |
+ |
Ok(format!("{} {} passed preflight", meta.name, meta.version))
|
|
647 |
+ |
});
|
|
648 |
+ |
}
|
|
649 |
+ |
|
| 513 |
650 |
|
// --- feature_flags() -> string: `--features a,b`, or "" when the app
|
| 514 |
651 |
|
// declares none. Returns the whole flag rather than a bare list so an
|
| 515 |
652 |
|
// app with no features cannot produce a dangling `--features`. ---
|
| 1029 |
1166 |
|
);
|
| 1030 |
1167 |
|
}
|
| 1031 |
1168 |
|
|
|
1169 |
+ |
/// The two failures that actually shipped, as regression cases.
|
|
1170 |
+ |
#[test]
|
|
1171 |
+ |
fn preflight_catches_a_dead_repository_url() {
|
|
1172 |
+ |
// pter 0.1.0: repository pointed at a URL that does not exist. It
|
|
1173 |
+ |
// published clean and the link is now permanent for that version.
|
|
1174 |
+ |
let meta = CrateMeta {
|
|
1175 |
+ |
name: "pter".into(),
|
|
1176 |
+ |
version: "0.1.0".into(),
|
|
1177 |
+ |
repository: Some("https://github.com/maxjacobson/pter".into()),
|
|
1178 |
+ |
description: Some("d".into()),
|
|
1179 |
+ |
licensed: true,
|
|
1180 |
+ |
};
|
|
1181 |
+ |
let problems = crate_publish_problems(&meta, false, &[]);
|
|
1182 |
+ |
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
1183 |
+ |
assert!(problems[0].contains("not publicly clonable"), "{problems:?}");
|
|
1184 |
+ |
|
|
1185 |
+ |
// Same metadata, reachable URL: nothing to report.
|
|
1186 |
+ |
assert!(crate_publish_problems(&meta, true, &[]).is_empty());
|
|
1187 |
+ |
}
|
|
1188 |
+ |
|
|
1189 |
+ |
#[test]
|
|
1190 |
+ |
fn preflight_requires_the_fields_crates_io_bakes_in() {
|
|
1191 |
+ |
let bare = CrateMeta {
|
|
1192 |
+ |
name: "x".into(),
|
|
1193 |
+ |
version: "0.1.0".into(),
|
|
1194 |
+ |
repository: None,
|
|
1195 |
+ |
description: None,
|
|
1196 |
+ |
licensed: false,
|
|
1197 |
+ |
};
|
|
1198 |
+ |
let problems = crate_publish_problems(&bare, false, &[]);
|
|
1199 |
+ |
assert_eq!(problems.len(), 3, "{problems:?}");
|
|
1200 |
+ |
assert!(problems.iter().any(|p| p.contains("repository")));
|
|
1201 |
+ |
assert!(problems.iter().any(|p| p.contains("description")));
|
|
1202 |
+ |
assert!(problems.iter().any(|p| p.contains("license")));
|
|
1203 |
+ |
}
|
|
1204 |
+ |
|
|
1205 |
+ |
#[test]
|
|
1206 |
+ |
fn preflight_rejects_republishing_the_same_version() {
|
|
1207 |
+ |
let meta = CrateMeta {
|
|
1208 |
+ |
name: "makeover".into(),
|
|
1209 |
+ |
version: "0.10.0".into(),
|
|
1210 |
+ |
repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
|
|
1211 |
+ |
description: Some("d".into()),
|
|
1212 |
+ |
licensed: true,
|
|
1213 |
+ |
};
|
|
1214 |
+ |
let problems = crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()]);
|
|
1215 |
+ |
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
1216 |
+ |
assert!(problems[0].contains("already published"), "{problems:?}");
|
|
1217 |
+ |
|
|
1218 |
+ |
// An unreleased version against the same history is fine.
|
|
1219 |
+ |
let mut next = meta.clone();
|
|
1220 |
+ |
next.version = "0.11.0".into();
|
|
1221 |
+ |
assert!(crate_publish_problems(&next, true, &["0.10.0".into()]).is_empty());
|
|
1222 |
+ |
}
|
|
1223 |
+ |
|
|
1224 |
+ |
#[test]
|
|
1225 |
+ |
fn crate_meta_reads_cargo_metadata_json() {
|
|
1226 |
+ |
let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0",
|
|
1227 |
+ |
"repository":"https://git.sr.ht/~maxmj/makeover","description":"themes",
|
|
1228 |
+ |
"license":"MIT"}]}"#;
|
|
1229 |
+ |
let m = crate_meta_from_json(raw).unwrap();
|
|
1230 |
+ |
assert_eq!(m.name, "makeover");
|
|
1231 |
+ |
assert_eq!(m.version, "0.10.0");
|
|
1232 |
+ |
assert!(m.licensed);
|
|
1233 |
+ |
assert_eq!(m.repository.as_deref(), Some("https://git.sr.ht/~maxmj/makeover"));
|
|
1234 |
+ |
|
|
1235 |
+ |
// license_file alone also counts as licensed; empty strings do not
|
|
1236 |
+ |
// count as present.
|
|
1237 |
+ |
let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"",
|
|
1238 |
+ |
"license_file":"LICENSE","description":""}]}"#;
|
|
1239 |
+ |
let m = crate_meta_from_json(lf).unwrap();
|
|
1240 |
+ |
assert!(m.licensed);
|
|
1241 |
+ |
assert!(m.description.is_none());
|
|
1242 |
+ |
}
|
|
1243 |
+ |
|
| 1032 |
1244 |
|
#[test]
|
| 1033 |
1245 |
|
fn expand_tilde_handles_home() {
|
| 1034 |
1246 |
|
unsafe { std::env::set_var("HOME", "/home/test") };
|