Skip to main content

max / makenotwork

bento-driver: expand `~/` in the local dest_dir `dest_dir` deserialized straight into a PathBuf, so `"~/Dist/goingson"` from the shipped example stayed literal and create_dir_all made a directory *named* `~` under the cwd -- dropping the signed DMG where the operator would never look. The example's comment claimed "Resolved locally, so `~` is fine here", which was the opposite of true. Expand a leading `~/` against this machine's HOME at deserialize time. This is the counterpart to reject_tilde, not an exception to it: a `~` in a [plan] path is unexpandable because that home is the Mac's, but dest_dir is on this host, so our own HOME is the right answer. `~user` is refused rather than guessed. Verified with the shipped example config: resolves to /home/max/Dist/ goingson, no literal `~` left behind.
Author: Max Johnson <me@maxj.phd> · 2026-07-17 00:00 UTC
Commit: af013d21c15fce24f01adc05da616677c3a60cd3
Parent: 8060d1a
3 files changed, +75 insertions, -2 deletions
@@ -34,6 +34,8 @@ dist_root = "/Users/max/Dist/goingson/macos"
34 34 # dmg_name = "GoingsOn_0.4.1_aarch64.dmg" # optional; default shown
35 35
36 36 [local]
37 - # Where on THIS host (fw13) to pull the signed DMG to. Resolved locally, so `~`
38 - # is fine here.
37 + # Where on THIS host (fw13) to pull the signed DMG to. This one IS on this
38 + # machine, so a leading `~/` is expanded against your own HOME (`~user` is not).
39 + # That is the whole difference from the `[plan]` paths above: their home is the
40 + # Mac's, which this side cannot know, so there a `~` is rejected instead.
39 41 dest_dir = "~/Dist/goingson"
@@ -21,6 +21,7 @@
21 21
22 22 use anyhow::{Context, Result};
23 23 use ops_exec::{Action, Executor, LogSink, ObserveKind, RunOutput, Step};
24 + use std::path::PathBuf;
24 25
25 26 /// Everything needed to drive one app's macOS release on a build host.
26 27 #[derive(Clone, Debug)]
@@ -60,6 +61,36 @@ fn reject_tilde(field: &str, value: &str) -> Result<()> {
60 61 Ok(())
61 62 }
62 63
64 + /// Expand a leading `~/` in a path that lives on THIS machine.
65 + ///
66 + /// The counterpart to [`reject_tilde`], and the reason the two differ: a `~` in
67 + /// a *build-host* path is unexpandable (that host's home is not ours), but the
68 + /// local destination is right here, so our own `HOME` is the correct answer and
69 + /// the tilde can be honored rather than refused.
70 + ///
71 + /// `~user` is refused: resolving another user's home means a passwd lookup, and
72 + /// nothing needs it.
73 + pub fn expand_local_tilde(path: &str) -> Result<PathBuf> {
74 + let Some(rest) = path.strip_prefix('~') else {
75 + return Ok(PathBuf::from(path));
76 + };
77 + let home = std::env::var_os("HOME")
78 + .filter(|h| !h.is_empty())
79 + .context("expanding `~`: HOME is not set")?;
80 + match rest {
81 + "" => Ok(PathBuf::from(home)),
82 + _ => match rest.strip_prefix('/') {
83 + Some(tail) => Ok(PathBuf::from(home).join(tail)),
84 + // `~max/x`, `~x` — not a home-relative path we resolve.
85 + None => anyhow::bail!(
86 + "{path:?} uses `~{}`, which is not supported: only `~/` (your own home) is \
87 + expanded. Write it absolute.",
88 + rest.split('/').next().unwrap_or(rest)
89 + ),
90 + },
91 + }
92 + }
93 +
63 94 impl ReleasePlan {
64 95 /// Validate the plan's build-host paths before any step runs.
65 96 pub fn validate(&self) -> Result<()> {
@@ -276,6 +307,31 @@ mod tests {
276 307 assert!(env_tilde.validate().is_err());
277 308 }
278 309
310 + /// The local counterpart to `validate_rejects_tilde_in_build_host_paths`:
311 + /// here the home IS ours, so `~` resolves instead of being refused.
312 + #[test]
313 + fn expand_local_tilde_resolves_against_our_own_home() {
314 + let home = std::env::var("HOME").expect("HOME set in the test env");
315 + assert_eq!(expand_local_tilde("~/Dist/goingson").unwrap(), PathBuf::from(&home).join("Dist/goingson"));
316 + assert_eq!(expand_local_tilde("~").unwrap(), PathBuf::from(&home));
317 + // The bug this closes: the tilde must not survive into the path.
318 + assert!(!expand_local_tilde("~/Dist/goingson").unwrap().starts_with("~"));
319 + }
320 +
321 + #[test]
322 + fn expand_local_tilde_leaves_other_paths_alone() {
323 + assert_eq!(expand_local_tilde("/Users/max/Dist").unwrap(), PathBuf::from("/Users/max/Dist"));
324 + assert_eq!(expand_local_tilde("Dist/goingson").unwrap(), PathBuf::from("Dist/goingson"));
325 + // A `~` that isn't leading is just a character.
326 + assert_eq!(expand_local_tilde("/tmp/a~b").unwrap(), PathBuf::from("/tmp/a~b"));
327 + }
328 +
329 + #[test]
330 + fn expand_local_tilde_refuses_another_users_home() {
331 + let err = expand_local_tilde("~max/Dist").unwrap_err().to_string();
332 + assert!(err.contains("~max"), "should name what it saw: {err}");
333 + }
334 +
279 335 #[test]
280 336 fn validate_accepts_absolute_build_host_paths() {
281 337 let mut plan = go_plan("/Users/max/Code/Apps/goingson", "/Users/max/Dist/goingson/macos");
@@ -45,9 +45,24 @@ struct PlanSection {
45 45
46 46 #[derive(Debug, Deserialize)]
47 47 struct LocalSection {
48 + /// `~/...` is expanded here (against this machine's HOME) — unlike the
49 + /// `[plan]` paths, which are on the build host and are rejected outright.
50 + /// Without this, a `PathBuf` deserialized straight from `"~/Dist/goingson"`
51 + /// stays literal and `create_dir_all` cheerfully makes a directory *named*
52 + /// `~` under the cwd, quietly dropping the signed DMG somewhere the
53 + /// operator will never look.
54 + #[serde(deserialize_with = "de_local_path")]
48 55 dest_dir: PathBuf,
49 56 }
50 57
58 + fn de_local_path<'de, D>(d: D) -> Result<PathBuf, D::Error>
59 + where
60 + D: serde::Deserializer<'de>,
61 + {
62 + let s = String::deserialize(d)?;
63 + bento_driver::expand_local_tilde(&s).map_err(serde::de::Error::custom)
64 + }
65 +
51 66 #[tokio::main]
52 67 async fn main() -> Result<()> {
53 68 tracing_subscriber::fmt()