Skip to main content

max / makenotwork

Release sando v0.2.4: let an aux repo check out to a nested dir checkout_dir was validated as a single path component, so an aux repo could only land directly under the workdir. docengine moved to Libraries/docengine on 2026-07-30 and both the server and multithreaded path-dep it as ../../Libraries/docengine, which no aux_repo could satisfy, so main has been unbuildable since that move. Accept a relative path of plain components instead, still rejecting a leading slash, empty segments and dot-dot, and now also rejecting one checkout nested inside another, which would bury it. checkout_worktree already created the parent, so nothing downstream changes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 21:15 UTC
Signed with PGP, not checked
Commit: a3741e21e713edc926388977537ce896c89e6b90
Parent: cbfa9a8
4 files changed, +90 insertions, -20 deletions
@@ -1611,7 +1611,7 @@
1611 1611
1612 1612 [[package]]
1613 1613 name = "sando-daemon"
1614 - version = "0.2.3"
1614 + version = "0.2.4"
1615 1615 dependencies = [
1616 1616 "anyhow",
1617 1617 "async-trait",
M sando/sando.toml +12 -1
@@ -27,7 +27,7 @@
27 27 # MNW worktree) carries `synckit-client = { path = "../../synckit/synckit-client" }`
28 28 # after synckit moved to its own repo; from <workdir>/<sha>/mnw-cli that resolves
29 29 # to <workdir>/synckit, so synckit must be checked out there. checkout_dir is a
30 - # single component under the workdir; the checkout is shared across shas and
30 + # path under the workdir, nesting allowed; the checkout is shared across shas and
31 31 # refreshed to `branch` HEAD each build. See the maintainer wiki, sando-overview.
32 32 [[aux_repo]]
33 33 name = "synckit"
@@ -36,6 +36,17 @@
36 36 branch = "main"
37 37 checkout_dir = "synckit"
38 38
39 + # The server and multithreaded both carry
40 + # `docengine = { path = "../../Libraries/docengine" }` after docengine left
41 + # MNW/shared on 2026-07-30. From <workdir>/<sha>/server that resolves to
42 + # <workdir>/Libraries/docengine, hence the nested checkout_dir.
43 + [[aux_repo]]
44 + name = "docengine"
45 + bare_path = "/srv/sando/docengine.git"
46 + upstream = "git@ssh.makenot.work:max/docengine.git"
47 + branch = "main"
48 + checkout_dir = "Libraries/docengine"
49 +
39 50 [backup]
40 51 # Source of the prod-backup clone used by migration_dry_run on the Sando host.
41 52 # For localhost dev this can be a file:// path to a fixture dump. In prod we
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "sando-daemon"
3 - version = "0.2.3"
3 + version = "0.2.4"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
@@ -45,8 +45,11 @@
45 45 pub upstream: String,
46 46 /// Branch whose HEAD is checked out.
47 47 pub branch: String,
48 - /// Where the worktree lands, relative to `cfg.workdir`. Must be a single safe
49 - /// path component (no `..`, not absolute) so it stays under the workdir.
48 + /// Where the worktree lands, relative to `cfg.workdir`. May name a nested
49 + /// location (`Libraries/docengine`), because a path dep resolves to wherever
50 + /// the dev tree keeps the crate and Sando has to match that shape. Every
51 + /// component must be a plain name — no leading slash, no `..` — so the
52 + /// checkout stays under the workdir.
50 53 pub checkout_dir: String,
51 54 }
52 55
@@ -302,30 +305,40 @@
302 305 );
303 306 }
304 307 }
305 - let mut seen_dirs = std::collections::HashSet::new();
308 + let mut seen_dirs: Vec<Vec<&str>> = Vec::new();
306 309 for aux in &self.aux_repos {
307 310 anyhow::ensure!(
308 311 !aux.name.is_empty() && !aux.bare_path.is_empty() && !aux.branch.is_empty(),
309 312 "aux_repo entry has an empty name/bare_path/branch"
310 313 );
311 - // `checkout_dir` becomes a `workdir.join(..)`; keep it a single safe
312 - // component so an aux repo can never write outside the workdir or
313 - // collide with a per-sha worktree dir.
314 + // `checkout_dir` becomes a `workdir.join(..)`. Nesting is allowed —
315 + // docengine lives at `Libraries/docengine` in the dev tree and the
316 + // path dep resolves to that shape — but every component must be a
317 + // plain name so an aux repo can never write outside the workdir.
314 318 let dir = &aux.checkout_dir;
319 + let parts: Vec<&str> = dir.split('/').collect();
315 320 anyhow::ensure!(
316 321 !dir.is_empty()
317 - && !dir.contains('/')
318 322 && !dir.contains('\\')
319 - && dir != "."
320 - && dir != "..",
321 - "aux_repo {} has an unsafe checkout_dir {dir:?} (must be a single path component, \
322 - no separators or dot-dot)",
323 + && parts
324 + .iter()
325 + .all(|c| !c.is_empty() && *c != "." && *c != ".."),
326 + "aux_repo {} has an unsafe checkout_dir {dir:?} (must be a relative path of \
327 + plain components: no leading slash, empty segments, or dot-dot)",
323 328 aux.name,
324 329 );
325 - anyhow::ensure!(
326 - seen_dirs.insert(dir.as_str()),
327 - "two aux_repo entries share checkout_dir {dir:?}; they would clobber each other"
328 - );
330 + // Two checkouts may not share a dir, and neither may sit inside the
331 + // other: `git worktree add` into a path under a live worktree buries
332 + // one checkout in the other's tree, and whichever builds second wins.
333 + for prior in &seen_dirs {
334 + let common = prior.len().min(parts.len());
335 + anyhow::ensure!(
336 + prior[..common] != parts[..common],
337 + "two aux_repo entries share or nest checkout_dir {dir:?}; \
338 + they would clobber each other"
339 + );
340 + }
341 + seen_dirs.push(parts);
329 342 }
330 343 Ok(())
331 344 }
@@ -508,9 +521,33 @@
508 521 assert_eq!(a.checkout_dir, "synckit");
509 522 }
510 523
524 + #[test]
525 + fn aux_repo_with_nested_checkout_dir_is_accepted() {
526 + let topo = topo_with_aux(
527 + r#"
528 + [[aux_repo]]
529 + name = "docengine"
530 + bare_path = "/srv/sando/docengine.git"
531 + upstream = "git@ssh.makenot.work:max/docengine.git"
532 + branch = "main"
533 + checkout_dir = "Libraries/docengine""#,
534 + )
535 + .expect("a nested checkout_dir is a valid location");
536 + assert_eq!(topo.aux_repos[0].checkout_dir, "Libraries/docengine");
537 + }
538 +
511 539 #[test]
512 540 fn aux_repo_with_traversing_checkout_dir_is_rejected() {
513 - for bad in ["../escape", "a/b", "..", "."] {
541 + for bad in [
542 + "../escape",
543 + "a/../../escape",
544 + "a/./b",
545 + "a//b",
546 + "/abs",
547 + "a/",
548 + "..",
549 + ".",
550 + ] {
514 551 let err = topo_with_aux(&format!(
515 552 r#"
516 553 [[aux_repo]]
@@ -545,7 +582,29 @@
545 582 )
546 583 .unwrap_err()
547 584 .to_string();
548 - assert!(err.contains("share checkout_dir"), "{err}");
585 + assert!(err.contains("share or nest checkout_dir"), "{err}");
586 + }
587 +
588 + #[test]
589 + fn aux_repo_nested_inside_another_checkout_dir_is_rejected() {
590 + let err = topo_with_aux(
591 + r#"
592 + [[aux_repo]]
593 + name = "outer"
594 + bare_path = "/srv/sando/outer.git"
595 + upstream = "u"
596 + branch = "main"
597 + checkout_dir = "Libraries"
598 + [[aux_repo]]
599 + name = "inner"
600 + bare_path = "/srv/sando/inner.git"
601 + upstream = "u"
602 + branch = "main"
603 + checkout_dir = "Libraries/docengine""#,
604 + )
605 + .unwrap_err()
606 + .to_string();
607 + assert!(err.contains("share or nest checkout_dir"), "{err}");
549 608 }
550 609
551 610 #[test]