| 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 |
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 |
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 |
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]
|