Skip to main content

max / makenotwork

bento: preflight the crates.io credentials instead of moving the token The token stays in cargo's own 0600 store on the publishing host, where cargo already finds it. Routing it through secret() would have been a downgrade: ops-exec renders env pairs into the shell line, so CARGO_REGISTRY_TOKEN would sit in the ssh command and be visible in ps on the build host for the length of the upload. Logs are unaffected either way -- run_command_into_sink streams only stdout and stderr, and commands are not persisted. Preflight now asks the publishing host whether cargo has credentials, so a missing token fails in seconds rather than at the upload, which runs last and is the irreversible step.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 21:25 UTC
Commit: d982825577d9cacb663fbbb13346b8f1a14d85b6
Parent: 09fef19
2 files changed, +59 insertions, -7 deletions
@@ -412,8 +412,21 @@ pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
412 412 /// Checks only what crates.io records permanently. A published version cannot
413 413 /// be edited, only yanked, and yanking does not correct a wrong URL — so these
414 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> {
415 + pub fn crate_publish_problems(
416 + meta: &CrateMeta,
417 + repo_clonable: bool,
418 + published: &[String],
419 + credentials_present: bool,
420 + ) -> Vec<String> {
416 421 let mut out = Vec::new();
422 + if !credentials_present {
423 + out.push(
424 + "no crates.io credentials on the publishing host: `cargo login` there first. \
425 + Checked now rather than at the upload, so this fails in seconds instead of \
426 + after a full build and verify."
427 + .to_string(),
428 + );
429 + }
417 430 match &meta.repository {
418 431 None => out.push(
419 432 "no `repository` field: the crates.io page will show no source link, permanently"
@@ -632,8 +645,18 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
632 645 .unwrap_or(false)
633 646 });
634 647
648 + // Ask the publishing host whether cargo has credentials, rather
649 + // than moving the token anywhere. It stays in cargo's own 0600
650 + // store; a shell line carrying it would be visible in `ps`.
651 + let creds = ctx
652 + .run(&ctx.build_host.clone(), "cargo login --help >/dev/null 2>&1 && \
653 + test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
654 + || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"")
655 + .map(|(code, _)| code == 0)
656 + .unwrap_or(false);
657 +
635 658 let published = ctx.published_versions(&meta.name);
636 - let problems = crate_publish_problems(&meta, clonable, &published);
659 + let problems = crate_publish_problems(&meta, clonable, &published, creds);
637 660 if !problems.is_empty() {
638 661 return Err(format!(
639 662 "{} {} is not safe to publish:\n - {}",
@@ -1178,12 +1201,12 @@ mod tests {
1178 1201 description: Some("d".into()),
1179 1202 licensed: true,
1180 1203 };
1181 - let problems = crate_publish_problems(&meta, false, &[]);
1204 + let problems = crate_publish_problems(&meta, false, &[], true);
1182 1205 assert_eq!(problems.len(), 1, "{problems:?}");
1183 1206 assert!(problems[0].contains("not publicly clonable"), "{problems:?}");
1184 1207
1185 1208 // Same metadata, reachable URL: nothing to report.
1186 - assert!(crate_publish_problems(&meta, true, &[]).is_empty());
1209 + assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
1187 1210 }
1188 1211
1189 1212 #[test]
@@ -1195,7 +1218,7 @@ mod tests {
1195 1218 description: None,
1196 1219 licensed: false,
1197 1220 };
1198 - let problems = crate_publish_problems(&bare, false, &[]);
1221 + let problems = crate_publish_problems(&bare, false, &[], true);
1199 1222 assert_eq!(problems.len(), 3, "{problems:?}");
1200 1223 assert!(problems.iter().any(|p| p.contains("repository")));
1201 1224 assert!(problems.iter().any(|p| p.contains("description")));
@@ -1211,14 +1234,37 @@ mod tests {
1211 1234 description: Some("d".into()),
1212 1235 licensed: true,
1213 1236 };
1214 - let problems = crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()]);
1237 + let problems = crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true);
1215 1238 assert_eq!(problems.len(), 1, "{problems:?}");
1216 1239 assert!(problems[0].contains("already published"), "{problems:?}");
1217 1240
1218 1241 // An unreleased version against the same history is fine.
1219 1242 let mut next = meta.clone();
1220 1243 next.version = "0.11.0".into();
1221 - assert!(crate_publish_problems(&next, true, &["0.10.0".into()]).is_empty());
1244 + assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty());
1245 + }
1246 +
1247 + /// Missing credentials must surface at preflight, not at the upload. The
1248 + /// publish step is the irreversible one and runs last, after a full build
1249 + /// and verify; discovering there that cargo cannot authenticate wastes the
1250 + /// whole run.
1251 + #[test]
1252 + fn preflight_reports_missing_credentials_up_front() {
1253 + let meta = CrateMeta {
1254 + name: "makeover".into(),
1255 + version: "0.11.0".into(),
1256 + repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
1257 + description: Some("d".into()),
1258 + licensed: true,
1259 + };
1260 + // Metadata is perfect; only the token is absent.
1261 + let problems = crate_publish_problems(&meta, true, &[], false);
1262 + assert_eq!(problems.len(), 1, "{problems:?}");
1263 + assert!(problems[0].contains("credentials"), "{problems:?}");
1264 + assert!(problems[0].contains("cargo login"), "should say how to fix it");
1265 +
1266 + // Present: nothing to report.
1267 + assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
1222 1268 }
1223 1269
1224 1270 #[test]
@@ -41,6 +41,12 @@ repo = "~/Code/Apps/audiofiles"
41 41
42 42 # Library crates published to crates.io. Same two-file split as the apps: the
43 43 # pointer lives here, `kind = "library"` and the rest in each repo's bento.toml.
44 + #
45 + # crates.io credentials are NOT configured here. The token lives in cargo's own
46 + # 0600 store (~/.cargo/credentials.toml) on whichever host publishes, set once
47 + # with `cargo login`. Bento only checks it is present, during preflight, so a
48 + # missing token fails in seconds rather than at the irreversible upload. Passing
49 + # it through a recipe would put it in the process list on the build host.
44 50
45 51 [app.makeover]
46 52 repo = "~/Code/Libraries/makeover"