devflow_core/git.rs
1//! Git-flow operations implemented with plain `git` commands.
2
3use crate::config::GitFlowConfig;
4use crate::phase_id::PhaseId;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::time::{Duration, Instant};
8use tracing::{debug, info, warn};
9
10/// Errors produced by git-flow operations.
11#[derive(Debug, thiserror::Error)]
12pub enum GitError {
13 /// Spawning git failed.
14 #[error("failed to execute git: {0}")]
15 Io(#[from] std::io::Error),
16 /// Git returned a non-success status.
17 #[error("git command failed: {0}")]
18 Command(String),
19}
20
21/// Git's own list of repository-local environment variables, as reported by
22/// `git rev-parse --local-env-vars` (15 entries on git 2.55).
23///
24/// Kept as a constant rather than shelled out per call so building a command
25/// stays free of process spawns; `local_env_vars_match_git` asserts it still
26/// agrees with the installed git, so a version that adds one fails loudly
27/// instead of silently reopening the hole.
28pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
29 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
30 "GIT_CONFIG",
31 "GIT_CONFIG_PARAMETERS",
32 "GIT_CONFIG_COUNT",
33 "GIT_OBJECT_DIRECTORY",
34 "GIT_DIR",
35 "GIT_WORK_TREE",
36 "GIT_IMPLICIT_WORK_TREE",
37 "GIT_GRAFT_FILE",
38 "GIT_INDEX_FILE",
39 "GIT_NO_REPLACE_OBJECTS",
40 "GIT_REPLACE_REF_BASE",
41 "GIT_PREFIX",
42 "GIT_SHALLOW_FILE",
43 "GIT_COMMON_DIR",
44];
45
46/// Variables that are not repository-local — and so absent from
47/// `--local-env-vars` — but still redirect where git reads or writes.
48///
49/// `GIT_CEILING_DIRECTORIES` is included for completeness rather than
50/// because a live path needs it (27-REVIEW WR-02): every production call
51/// site passes an explicit `current_dir` that is already a repository root,
52/// so git's upward discovery — the only thing this variable constrains —
53/// never runs. Scrubbing an unset variable costs nothing, and including it
54/// means no future call site that *does* rely on discovery has to
55/// rediscover the reasoning.
56pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
57 "GIT_NAMESPACE",
58 "GIT_DISCOVERY_ACROSS_FILESYSTEM",
59 "GIT_CEILING_DIRECTORIES",
60];
61
62/// A `git` command pinned to `repo` **and** stripped of every inherited
63/// variable that could redirect it somewhere else.
64///
65/// Use this for every production git invocation instead of building
66/// `Command::new("git")` directly. `GIT_EXEC_PATH` is deliberately left
67/// alone: it only locates git's own helper binaries and cannot change
68/// which repository git acts on.
69///
70/// Clearing `GIT_CONFIG_COUNT` is sufficient to neutralize any inherited
71/// `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` pair — git only reads those when
72/// the count is set — so they need no separate sweep.
73pub fn git_command(repo: &Path) -> Command {
74 hermetic_command("git", repo)
75}
76
77/// As [`git_command`], for a program that is not `git` itself but will
78/// shell out to it — `cargo`, whose build scripts invoke `git`, is the
79/// motivating case. The redirecting variables are inherited all the way
80/// down a process tree, so scrubbing only the direct `git` calls would
81/// leave that path open.
82///
83/// The scrub is unconditional: there is no bypass parameter, no
84/// environment variable, and no config lookup that can turn it back on.
85/// There is no legitimate reason a DevFlow-issued command should silently
86/// redirect via an inherited variable — an operator who wants DevFlow to
87/// act on a different repository passes it a different path (D-01).
88pub fn hermetic_command(program: &str, dir: &Path) -> Command {
89 let mut cmd = Command::new(program);
90 cmd.current_dir(dir);
91 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
92 cmd.env_remove(var);
93 }
94 cmd
95}
96
97/// Repository helper bound to a project root.
98#[derive(Debug, Clone)]
99pub struct GitFlow {
100 root: PathBuf,
101 config: GitFlowConfig,
102}
103
104/// Summary of a feature branch for the `devflow list` command.
105#[derive(Debug, Clone)]
106pub struct BranchInfo {
107 /// Branch name (e.g. "feature/phase-05").
108 pub name: String,
109 /// Number of commits this branch has that develop doesn't.
110 pub ahead: usize,
111 /// Number of commits develop has that this branch doesn't.
112 pub behind: usize,
113 /// ISO-8601 date of the last commit on this branch.
114 pub last_commit: String,
115}
116
117impl GitFlow {
118 /// Create a git-flow helper for a project root, using the hardcoded
119 /// git-flow constants (`main`, `develop`, `feature/`).
120 pub fn new(root: impl AsRef<Path>) -> Self {
121 Self {
122 root: root.as_ref().to_path_buf(),
123 config: GitFlowConfig::default(),
124 }
125 }
126
127 /// Create a feature branch from the develop branch.
128 ///
129 /// Returns an error if the branch already exists (use
130 /// [`Self::feature_start_force`] to overwrite).
131 pub fn feature_start(&self, phase: PhaseId) -> Result<String, GitError> {
132 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
133 info!("creating feature branch: {branch}");
134 self.git(["checkout", &self.config.develop])?;
135 self.git(["checkout", "-b", &branch])?;
136 Ok(branch)
137 }
138
139 /// Create or reset a feature branch, overwriting it if it already exists.
140 pub fn feature_start_force(&self, phase: PhaseId) -> Result<String, GitError> {
141 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
142 warn!("force-creating feature branch: {branch}");
143 self.git(["checkout", &self.config.develop])?;
144 self.git(["checkout", "-B", &branch])?;
145 Ok(branch)
146 }
147
148 /// Merge a feature branch into develop and delete it.
149 pub fn feature_finish(&self, phase: PhaseId) -> Result<String, GitError> {
150 let branch = self.merge_feature_into_develop(phase)?;
151 self.git(["branch", "-d", &branch])?;
152 Ok(branch)
153 }
154
155 /// Merge a feature branch into develop without deleting it.
156 ///
157 /// Default DevFlow runs keep the feature branch checked out in a linked
158 /// worktree, so deletion belongs to the later best-effort cleanup hook.
159 pub fn merge_feature_into_develop(&self, phase: PhaseId) -> Result<String, GitError> {
160 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
161 info!("merging feature branch: {branch}");
162 self.git(["checkout", &self.config.develop])?;
163 self.git(["merge", "--no-ff", &branch])?;
164 Ok(branch)
165 }
166
167 /// Whether a phase feature branch has nothing left to merge into develop.
168 ///
169 /// An absent branch is not proof of a merge. Callers must fail closed
170 /// rather than treating a deleted or never-created branch as shipped.
171 pub fn is_merged_into_develop(&self, phase: PhaseId) -> bool {
172 let branch = format!("{}phase-{}", self.config.feature_prefix, phase.padded());
173 if !self.branch_exists(&branch) {
174 return false;
175 }
176
177 git_command(&self.root)
178 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
179 .output()
180 .map(|output| output.status.success())
181 .unwrap_or(false)
182 }
183
184 /// Create or reset a release branch from the current `HEAD`.
185 ///
186 /// The release branch is cut from wherever the caller currently is — the
187 /// branch being shipped — not from `develop`. `devflow ship` writes the
188 /// version bump into the working tree first, so branching from `HEAD`
189 /// keeps any commits unique to the shipped branch in the release.
190 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
191 let branch = format!("release/{version}");
192 info!("creating release branch: {branch}");
193 self.git(["checkout", "-B", &branch])?;
194 Ok(branch)
195 }
196
197 /// Merge a release branch into main and develop, tag it, and delete it.
198 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
199 let branch = format!("release/{version}");
200 info!("finishing release branch: {branch}");
201 self.git(["checkout", &self.config.main])?;
202 self.git(["merge", "--no-ff", &branch])?;
203 // `-c tag.gpgSign=false` scopes the override to this invocation only
204 // (never the user's global/repo config) — without it, a global
205 // `tag.gpgsign=true` forces this lightweight tag into an
206 // annotated+signed one requiring a message, which blocks on
207 // `$EDITOR` in what must be a headless, unattended flow (Phase 13
208 // dogfood finding).
209 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
210 self.git(["checkout", &self.config.develop])?;
211 self.git(["merge", "--no-ff", &branch])?;
212 self.git(["branch", "-d", &branch])?;
213 Ok(branch)
214 }
215
216 /// Create an annotated-free lightweight tag at the current `HEAD`.
217 ///
218 /// Passes `-c tag.gpgSign=false` scoped to this invocation only — a
219 /// global `tag.gpgsign=true` (common for developers who sign their own
220 /// tags) otherwise forces this lightweight tag into an annotated+signed
221 /// one requiring a message, which blocks on `$EDITOR` in what must be a
222 /// headless, unattended flow (Phase 13 dogfood finding: VersionBump hung
223 /// on a live `devflow start --mode auto` run).
224 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
225 info!("tagging {tag}");
226 self.git(["-c", "tag.gpgSign=false", "tag", tag])
227 }
228
229 /// Delete a single local branch.
230 ///
231 /// With `force`, uses `git branch -D` (deletes even if unmerged); otherwise
232 /// `git branch -d` (refuses to delete unmerged work). Protected branches
233 /// (`main`, `develop`) are never deleted.
234 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
235 if branch == self.config.main || branch == self.config.develop {
236 return Err(GitError::Command(format!(
237 "refusing to delete protected branch `{branch}`"
238 )));
239 }
240 let flag = if force { "-D" } else { "-d" };
241 if force {
242 warn!("force-deleting branch: {branch}");
243 } else {
244 info!("deleting branch: {branch}");
245 }
246 self.git(["branch", flag, branch])
247 }
248
249 /// Whether a local branch exists.
250 pub fn branch_exists(&self, branch: &str) -> bool {
251 git_command(&self.root)
252 .args([
253 "rev-parse",
254 "--verify",
255 "--quiet",
256 &format!("refs/heads/{branch}"),
257 ])
258 .output()
259 .map(|o| o.status.success())
260 .unwrap_or(false)
261 }
262
263 /// The commit SHA at the tip of `branch`.
264 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
265 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
266 }
267
268 /// Create `branch` at `start_point` if it does not already exist, without
269 /// checking it out (leaves the current checkout untouched).
270 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
271 if self.branch_exists(branch) {
272 return Ok(());
273 }
274 self.git(["branch", branch, start_point])
275 }
276
277 /// Check out an existing branch in the main worktree.
278 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
279 debug!("checking out branch: {branch}");
280 self.git(["checkout", branch])
281 }
282
283 /// Delete `branch` on `origin` (best-effort; errors if no remote/branch).
284 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
285 info!("deleting remote branch: {branch}");
286 self.git(["push", "origin", "--delete", branch])
287 }
288
289 /// Whether the repository has at least one configured remote.
290 pub fn has_remote(&self) -> bool {
291 self.git_output(["remote"])
292 .map(|s| !s.trim().is_empty())
293 .unwrap_or(false)
294 }
295
296 /// Push `branch` to `origin`, setting upstream.
297 pub fn push(&self, branch: &str) -> Result<(), GitError> {
298 info!("pushing branch: {branch}");
299 self.git(["push", "-u", "origin", branch])
300 }
301
302 /// Delete local branches already merged into `develop`.
303 ///
304 /// WR-04 (13-REVIEW.md): passes `develop` explicitly rather than relying
305 /// on `git branch --merged`'s default of "whatever HEAD currently is" —
306 /// if the main checkout is ever left on a branch other than `develop`
307 /// when this runs, an implicit baseline would silently prune branches
308 /// merged into that other branch instead.
309 ///
310 /// Deletion uses `-D`, not `-d`: `-d` verifies merged-into-HEAD, which
311 /// contradicts the `--merged develop` listing above in exactly the
312 /// checkout-not-on-develop scenario WR-04 targets (every genuinely
313 /// merged branch would be refused as "not fully merged"). The listing IS
314 /// the merge safety check. A branch git still refuses to delete (e.g.
315 /// checked out in a worktree) is logged and skipped so one failure
316 /// doesn't abort the rest of the sweep.
317 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
318 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
319 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
320 let mut deleted = Vec::new();
321 for line in output.lines() {
322 // git's porcelain marker is an exact two-char prefix ("* " for
323 // the current branch, "+ " for a worktree checkout, " "
324 // otherwise) — strip it positionally rather than trimming
325 // marker CHARACTERS, which would mangle a branch legitimately
326 // named e.g. "+foo" (WR-03, revised).
327 let branch = line
328 .strip_prefix("* ")
329 .or_else(|| line.strip_prefix("+ "))
330 .unwrap_or(line)
331 .trim();
332 // Skip blanks, protected trunks, and the detached-HEAD line
333 // ("(HEAD detached at ...)"), which is not a branch name.
334 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
335 continue;
336 }
337 info!("cleaning up merged branch: {branch}");
338 match self.git(["branch", "-D", branch]) {
339 Ok(()) => deleted.push(branch.to_string()),
340 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
341 }
342 }
343 Ok(deleted)
344 }
345
346 /// Stage all changes and commit with the given message.
347 /// Returns Ok(()) whether or not there were changes to commit.
348 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
349 debug!("committing all changes: {message}");
350 self.git(["add", "."])?;
351 // --allow-empty so we don't fail when there are no changes
352 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
353 Ok(()) => Ok(()),
354 // If the commit produced no changes and we used --allow-empty,
355 // this should still succeed. But just in case, ignore "nothing to commit".
356 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
357 Err(e) => Err(e),
358 }
359 }
360
361 /// Stage a single relative path and commit with the given message.
362 /// Mirrors `commit_all`, but scoped to one path, for hooks that must not
363 /// sweep in unrelated dirty state left by other hooks or the workflow.
364 /// Returns Ok(()) whether or not the path had changes to commit. Unlike
365 /// `commit_all`, a path with no changes produces **no commit** — it is a
366 /// genuine no-op, not a forced empty commit, so a caller such as
367 /// `hooks::version_bump` can never tag a release on a commit containing
368 /// nothing (19b/D-16).
369 pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
370 debug!("committing {relative_path}: {message}");
371 // `add` first so a brand-new file is known to git — a pathspec-only
372 // commit errors on a path git has never seen. The trailing pathspec is
373 // what actually scopes the commit: without it, `commit` writes whatever
374 // else is already in the index, which is exactly the sweep-in this
375 // function exists to prevent.
376 self.git(["add", relative_path])?;
377 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
378 Ok(()) => Ok(()),
379 // No forcing flag above, so this arm is now the live no-op path:
380 // a path with nothing staged makes git exit non-zero with
381 // "nothing to commit", and we convert that back to Ok(()) rather
382 // than let it propagate as an error (19b/D-16, T-19-11).
383 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
384 Err(e) => Err(e),
385 }
386 }
387
388 /// Return divergence from develop: (ahead, behind) commit counts.
389 ///
390 /// If currently on the develop branch, returns (0, 0).
391 /// `ahead` = commits on current branch not yet on develop.
392 /// `behind` = commits on develop not yet on current branch.
393 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
394 let current = self
395 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
396 .trim()
397 .to_string();
398 if current == self.config.develop {
399 return Ok((0, 0));
400 }
401 let ahead = self
402 .rev_count(&format!("{}..{current}", self.config.develop))
403 .unwrap_or(0);
404 let behind = self
405 .rev_count(&format!("{current}..{}", self.config.develop))
406 .unwrap_or(0);
407 Ok((ahead, behind))
408 }
409
410 /// List all feature branches with divergence from develop.
411 ///
412 /// Returns branches matching `feature/phase-*` with ahead/behind counts
413 /// and last commit dates. Protected branches (main, develop) are excluded.
414 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
415 let prefix = &self.config.feature_prefix;
416 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
417 let mut result = Vec::new();
418 for name in branches.lines().map(|l| l.trim()) {
419 if name.is_empty()
420 || name == self.config.main
421 || name == self.config.develop
422 || !name.starts_with(prefix)
423 {
424 continue;
425 }
426 let ahead = self
427 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
428 .unwrap_or(0);
429 let behind = self
430 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
431 .unwrap_or(0);
432 let last_commit = self
433 .git_output(["log", "-1", "--format=%aI", name])
434 .map(|s| s.trim().to_string())
435 .unwrap_or_default();
436 result.push(BranchInfo {
437 name: name.to_string(),
438 ahead,
439 behind,
440 last_commit,
441 });
442 }
443 // Sort by phase number so phase-01 comes before phase-10.
444 result.sort_by(|a, b| a.name.cmp(&b.name));
445 Ok(result)
446 }
447
448 /// Count revisions in the given range. Returns None if the command fails.
449 fn rev_count(&self, range: &str) -> Option<usize> {
450 self.git_output(["rev-list", "--count", range])
451 .ok()
452 .and_then(|s| s.trim().parse().ok())
453 }
454
455 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
456 debug!("git {}", args.join(" "));
457 // Pin the subprocess locale to C (Antigravity review, 19b): commit_path's
458 // "nothing to commit" match arm above compares against git's own
459 // English-locale output, which a non-English LC_ALL/LANG would
460 // localize, silently defeating the match and reopening 19b under a
461 // localized environment (T-19-14). Scoped to this one call path only.
462 let output = git_command(&self.root)
463 .args(args)
464 .env("LC_ALL", "C")
465 .env("LANG", "C")
466 .output()?;
467 if output.status.success() {
468 Ok(())
469 } else {
470 Err(GitError::Command(stderr_or_status(&output)))
471 }
472 }
473
474 /// Like [`git_raw`](Self::git_raw), but the error text combines stdout
475 /// with stderr instead of inspecting stderr alone.
476 ///
477 /// Discovered empirically while implementing 19b: `git commit`'s
478 /// "nothing to commit, working tree clean" message is written to
479 /// **stdout**, not stderr. `stderr_or_status` only ever inspects
480 /// `output.stderr`, so a plain `git_raw` error can never contain that
481 /// text — `commit_path`'s `nothing to commit` match arm (immediately
482 /// above its call site) would never fire, no matter how the arm itself
483 /// is written. This sibling exists solely so `commit_path` can see it;
484 /// `commit_all` keeps calling `git_raw` unchanged (D-17 out of scope),
485 /// and `git_raw`'s own error-mapping branch is untouched by this
486 /// addition.
487 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
488 debug!("git {}", args.join(" "));
489 let output = git_command(&self.root)
490 .args(args)
491 .env("LC_ALL", "C")
492 .env("LANG", "C")
493 .output()?;
494 if output.status.success() {
495 Ok(())
496 } else {
497 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
498 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
499 let combined = match (stderr.is_empty(), stdout.is_empty()) {
500 (false, false) => format!("{stderr}\n{stdout}"),
501 (false, true) => stderr,
502 (true, false) => stdout,
503 (true, true) => format!("exited with {}", output.status),
504 };
505 Err(GitError::Command(combined))
506 }
507 }
508
509 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
510 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
511 let output = git_command(&self.root).args(args).output()?;
512 if output.status.success() {
513 Ok(())
514 } else {
515 Err(GitError::Command(stderr_or_status(&output)))
516 }
517 }
518
519 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
520 let output = git_command(&self.root).args(args).output()?;
521 if output.status.success() {
522 Ok(String::from_utf8_lossy(&output.stdout).to_string())
523 } else {
524 Err(GitError::Command(stderr_or_status(&output)))
525 }
526 }
527}
528
529/// Result of checking whether `origin/main` is already an ancestor of
530/// `HEAD` — i.e. whether `scripts/sync-main-to-develop.sh` would be a no-op
531/// — WITHOUT issuing any `git fetch` (20d, review: Codex HIGH — a
532/// "read-only" preflight must not depend on the network).
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum AncestorStatus {
535 /// `origin/main` is an ancestor of `HEAD` — sync would be a no-op.
536 Ancestor,
537 /// `origin/main` resolves locally but is NOT an ancestor of `HEAD` —
538 /// develop has diverged and `scripts/sync-main-to-develop.sh` should be
539 /// run before cutting the next release.
540 Diverged,
541 /// `origin/main` does not resolve locally at all (never fetched, or no
542 /// remote configured). Distinct from [`Diverged`](Self::Diverged) so
543 /// the caller can degrade to an actionable "run `git fetch` first"
544 /// message instead of reporting a false divergence.
545 RefAbsent,
546}
547
548/// Check whether `origin/main` is an ancestor of `HEAD`, against
549/// ALREADY-FETCHED local refs — issues NO `git fetch`. Mirrors
550/// `scripts/sync-main-to-develop.sh`'s own `git merge-base --is-ancestor
551/// origin/main HEAD` invocation (`:41`), minus the preceding `git fetch`
552/// (`:38`), which mutates `.git/FETCH_HEAD`/tracking refs and would make a
553/// "read-only" preflight false (20d, review: Codex HIGH).
554pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
555 let ref_exists = git_command(project_root)
556 .args(["rev-parse", "--verify", "--quiet", "origin/main"])
557 .output()
558 .map(|out| out.status.success())
559 .unwrap_or(false);
560 if !ref_exists {
561 return AncestorStatus::RefAbsent;
562 }
563 let is_ancestor = git_command(project_root)
564 .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
565 .output()
566 .map(|out| out.status.success())
567 .unwrap_or(false);
568 if is_ancestor {
569 AncestorStatus::Ancestor
570 } else {
571 AncestorStatus::Diverged
572 }
573}
574
575/// Derive the crates.io publish order for a workspace's local-path members
576/// (e.g. `devflow-core` before `devflow`) — sourced from the workspace's own
577/// `[workspace] members` list and each member's own `[dependencies]`
578/// section (which member depends on which), never a hardcoded prose string
579/// (20d). Read-only; returns an empty `Vec` (never panics) if the workspace
580/// Cargo.toml or a member manifest cannot be read.
581pub fn publish_order(project_root: &Path) -> Vec<String> {
582 let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
583 return Vec::new();
584 };
585 let member_paths = workspace_member_paths(&root_contents);
586
587 let mut members: Vec<(String, String)> = Vec::new();
588 for path in &member_paths {
589 let manifest = project_root.join(path).join("Cargo.toml");
590 let Ok(contents) = std::fs::read_to_string(&manifest) else {
591 continue;
592 };
593 let name = package_name(&contents).unwrap_or_else(|| path.clone());
594 members.push((name, contents));
595 }
596
597 let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
598 let mut edges: Vec<(String, String)> = Vec::new();
599 for (name, contents) in &members {
600 for other in &names {
601 if other != name && member_depends_on(contents, other) {
602 edges.push((name.clone(), other.clone()));
603 }
604 }
605 }
606 topo_sort(names, edges)
607}
608
609/// Extract the `[workspace] members = [...]` array's quoted path entries.
610/// Hand-rolled, single-array-only scan (this project deliberately avoids a
611/// TOML parser dependency for its version/workspace tooling — see
612/// `version.rs`).
613fn workspace_member_paths(contents: &str) -> Vec<String> {
614 let Some(start) = contents.find("members") else {
615 return Vec::new();
616 };
617 let rest = &contents[start..];
618 let Some(open) = rest.find('[') else {
619 return Vec::new();
620 };
621 let Some(close) = rest[open..].find(']') else {
622 return Vec::new();
623 };
624 let inner = &rest[open + 1..open + close];
625 inner
626 .split(',')
627 .filter_map(|fragment| {
628 let fragment = fragment.trim();
629 let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
630 (!fragment.is_empty()).then(|| fragment.to_string())
631 })
632 .collect()
633}
634
635/// Extract a member manifest's `[package] name`.
636fn package_name(contents: &str) -> Option<String> {
637 let mut current = String::new();
638 for line in contents.lines() {
639 let trimmed = line.trim();
640 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
641 current = inner.trim().to_string();
642 continue;
643 }
644 if current == "package"
645 && let Some((key, value)) = trimmed.split_once('=')
646 && key.trim() == "name"
647 {
648 return Some(value.trim().trim_matches('"').to_string());
649 }
650 }
651 None
652}
653
654/// Whether a member manifest's `[dependencies]` section references
655/// `dep_name` — either `dep_name.workspace = true` or `dep_name = { ... }`
656/// under an inline `[dependencies]` table, OR the equally-valid expanded
657/// long-form section `[dependencies.dep_name]` (WR-03, phase 20 review): a
658/// manifest may spell a dependency out as its own section (e.g.
659/// `[dependencies.devflow-core]\nworkspace = true`), which parses to a
660/// section header of `"dependencies.devflow-core"` — never equal to the
661/// plain `"dependencies"` the inline-table branch below checks against, so
662/// that edge was previously dropped from `publish_order`'s topo-sort
663/// entirely.
664fn member_depends_on(contents: &str, dep_name: &str) -> bool {
665 let mut current = String::new();
666 for line in contents.lines() {
667 let trimmed = line.trim();
668 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
669 current = inner.trim().to_string();
670 if let Some(name) = current.strip_prefix("dependencies.")
671 && name == dep_name
672 {
673 return true;
674 }
675 continue;
676 }
677 if current != "dependencies" {
678 continue;
679 }
680 let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
681 if key == dep_name {
682 return true;
683 }
684 }
685 false
686}
687
688/// Kahn's-algorithm topological sort: `edges` are `(dependent, dependency)`
689/// pairs, meaning `dependent` must be published AFTER `dependency`. Falls
690/// back to appending whatever remains (rather than looping forever) if a
691/// cycle is present — a genuine cyclic Cargo dependency would already fail
692/// `cargo build` long before this check runs.
693fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
694 let mut result = Vec::new();
695 let mut published: Vec<String> = Vec::new();
696 let mut remaining = names;
697 while !remaining.is_empty() {
698 let ready: Vec<String> = remaining
699 .iter()
700 .filter(|name| {
701 edges
702 .iter()
703 .filter(|(dependent, _)| dependent == *name)
704 .all(|(_, dep)| published.contains(dep))
705 })
706 .cloned()
707 .collect();
708 if ready.is_empty() {
709 result.extend(remaining);
710 break;
711 }
712 for name in &ready {
713 published.push(name.clone());
714 result.push(name.clone());
715 }
716 remaining.retain(|name| !ready.contains(name));
717 }
718 result
719}
720
721// ---------------------------------------------------------------------------
722// tag-signing viability (20d, Pattern 4)
723// ---------------------------------------------------------------------------
724
725// REMOVED in v2.5.0 (999.86, D-04/D-08) — `pub enum SigningStatus` and
726// `pub fn classify_ssh_add_status` used to live here, immediately below this
727// banner. Both were `pub` items of this crate, so their removal is a breaking
728// change; it is enumerated in `CHANGELOG.md` under 2.5.0. The private
729// `inline_key_fingerprint` helper went with them, orphaned by D-03.
730//
731// Why they are gone: they PREDICTED tag-signing viability by classifying
732// `ssh-add -l`'s exit code and comparing fingerprints — that is, they inferred
733// it from the agent's identity list. An agent listing cannot see private key
734// material sitting unencrypted on disk, so the predictor tested a condition the
735// real signing operation does not require, and reported `NotViable` for a
736// perfectly signable key that no agent happened to hold. That is not a
737// hypothetical: it false-negatived on two separate release cuts with the
738// correct key present.
739//
740// What replaced them: `check_signing_viability` below, which establishes
741// viability by PERFORMING the operation — a bounded, non-interactive
742// `ssh-keygen -Y sign` over throwaway bytes in a private per-call workspace,
743// whose exit code is the whole verdict. A probe has no independent behaviour to
744// drift out of sync with what `git tag -s` actually does, which is the
745// structural property the predictor lacked rather than a bug it happened to
746// have.
747//
748// This note exists because a bare absence invites the mistake in reverse. Dead
749// public API that still reads like the sanctioned way to judge signing
750// viability is how the predictor survived review twice; do not reintroduce an
751// agent-membership check here under a new name.
752
753/// Outcome of the tag-signing viability check. Carries only a boolean-ish
754/// status plus an optional PUBLIC key fingerprint — never private key
755/// material or a full filesystem path (T-20-04, ASVS V6 / WR-02 — mirrors
756/// the existing "no path/username" discipline this project already applies
757/// elsewhere, e.g. `PhaseFinding`).
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub enum SigningViability {
760 /// Signing is viable. `fingerprint` is the matched public key's
761 /// `SHA256:...` fingerprint, when one could be extracted.
762 Viable { fingerprint: Option<String> },
763 /// Not viable, with an actionable (never key-leaking) reason.
764 NotViable { reason: String },
765 /// Could not be determined — tool absent, format unset with no key,
766 /// etc. Fail-soft: never a crash.
767 Unknown { reason: String },
768}
769
770/// `git config --get <key>`, scoped to `project_root`. `None` if unset or
771/// the command fails (missing `git`, not a repo, etc.) — never panics.
772fn git_config(project_root: &Path, key: &str) -> Option<String> {
773 let output = git_command(project_root)
774 .args(["config", "--get", key])
775 .output()
776 .ok()?;
777 if !output.status.success() {
778 return None;
779 }
780 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
781 (!value.is_empty()).then_some(value)
782}
783
784/// `ssh-keygen -lf <pub_key_path>`'s fingerprint (`SHA256:...`) — reads only
785/// the PUBLIC key file, never a private key, and returns only the hash
786/// token, never a filesystem path.
787fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
788 let path_str = pub_key_path.to_str()?;
789 let output = Command::new("ssh-keygen")
790 .args(["-lf", path_str])
791 .output()
792 .ok()?;
793 if !output.status.success() {
794 return None;
795 }
796 // Format: "<bits> SHA256:<hash> <comment> (<type>)"
797 String::from_utf8_lossy(&output.stdout)
798 .split_whitespace()
799 .nth(1)
800 .map(str::to_string)
801}
802
803/// Classifies a `user.signingkey` value the way `git` itself does (mirrors
804/// `man git-config`'s `user.signingKey` precedence, D-01): a `key::`-prefixed
805/// value is inline with the prefix stripped; otherwise a value starting with
806/// the deprecated raw `ssh-` compat form is inline as-is; otherwise the value
807/// is a filesystem path. Pure — no I/O, no `Path`, no `.exists()` — so the
808/// classification never depends on the host's filesystem.
809///
810/// The prefix decides unconditionally (D-02): a value that also happens to
811/// name an existing file (e.g. `ssh-key.pub`) is still classified inline,
812/// because git never stats the value. The raw allowlist is `ssh-` only
813/// (D-03) — `ecdsa-`/`sk-` bare forms are NOT added here; git treats those as
814/// paths, and they only reach the inline branch through the `key::` prefix.
815fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
816 let trimmed = signingkey.trim();
817 if let Some(remainder) = trimmed.strip_prefix("key::") {
818 Some(remainder)
819 } else if trimmed.starts_with("ssh-") {
820 Some(trimmed)
821 } else {
822 None
823 }
824}
825
826/// The SSHSIG namespace `git` itself writes into a tag signature.
827///
828/// Decoded byte-for-byte out of a real git-produced SSHSIG blob — this
829/// repository's own `v2.4.0` signed tag. After the `SSHSIG` magic and the
830/// uint32 version come the length-prefixed public key and then the
831/// namespace field, which reads `\0\0\0\x03git`; the following `sha512`
832/// hash-algorithm field lands exactly where that length says it should,
833/// which is what makes the offset reading self-checking rather than a
834/// guess.
835///
836/// Do NOT re-derive this value from documentation or from memory. The
837/// probe's entire worth is that it performs the operation git performs
838/// rather than approximating it, and a namespace that differs from git's
839/// would silently make the probe measure something git never does.
840const SSH_SIGN_NAMESPACE: &str = "git";
841
842/// Wall-clock ceiling for the signing probe (D-01).
843///
844/// `SSH_ASKPASS_REQUIRE=never` closes the passphrase-prompt route; this
845/// closes the rest. Both are required: the env var alone leaves non-askpass
846/// blocking routes open (a wedged `ssh-agent`, a stalled PKCS11 provider —
847/// reasoned, not measured), and a timeout alone would turn a working
848/// graphical askpass into a false `NotViable`.
849const SSH_SIGN_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
850
851/// Poll interval while waiting for the probe child to exit.
852const SSH_SIGN_PROBE_POLL: Duration = Duration::from_millis(25);
853
854/// A probe-workspace directory name unique to each individual CALL (F-8).
855///
856/// Per-*process* uniqueness is not enough: `cargo test` runs tests as
857/// parallel threads inside one process, so a name derived from the process
858/// id alone is shared by every concurrent probe. Two probes would collide,
859/// the loser's non-recursive `create_dir` would fail, and it would fail
860/// soft to `Unknown` — a flaky result that points at the probe rather than
861/// at the caller.
862///
863/// Three parts, `std` only (this crate adds no dependency): the process id,
864/// a process-wide counter incremented on every call, and a sub-millisecond
865/// time component. Extracted as its own function so the uniqueness property
866/// can be asserted directly rather than inferred from a probe result.
867fn probe_workspace_name() -> String {
868 use std::sync::atomic::{AtomicU64, Ordering};
869 static PROBE_SEQ: AtomicU64 = AtomicU64::new(0);
870
871 let seq = PROBE_SEQ.fetch_add(1, Ordering::Relaxed);
872 let nanos = std::time::SystemTime::now()
873 .duration_since(std::time::UNIX_EPOCH)
874 .map(|since| since.as_nanos())
875 .unwrap_or(0);
876 format!(
877 "devflow-sign-probe-{}-{}-{}",
878 std::process::id(),
879 seq,
880 nanos
881 )
882}
883
884/// What one run of the signing probe established. Five outcomes, mapped to
885/// fixed reason strings by class at the single call site — never composed
886/// from `ssh-keygen`'s own output (D-02, D-08).
887enum SignProbeOutcome {
888 /// The child exited zero: this key really can sign.
889 Signed,
890 /// The child exited non-zero: this key really cannot sign.
891 Rejected,
892 /// The child outlived [`SSH_SIGN_PROBE_TIMEOUT`] and was killed and
893 /// reaped.
894 TimedOut,
895 /// The child could not be spawned — `ssh-keygen` is absent.
896 ToolMissing,
897 /// The probe could not be set up or supervised at all (its workspace
898 /// could not be created, the payload could not be written, or the child
899 /// could not be polled). Fail-soft: an infrastructure problem is not
900 /// evidence about the key.
901 NotRun,
902}
903
904/// Removes its directory when dropped, so the workspace goes away on EVERY
905/// exit path from [`run_ssh_sign_probe`] — including an unwind (WR-07,
906/// 35-REVIEW).
907///
908/// The plain `remove_dir_all` statement this replaces covered every `return`
909/// inside `sign_probe_within`, which is what its comment was written for, but a
910/// panic anywhere in that function skipped it. Over many `release --check` runs
911/// on a long-lived host that is unbounded accumulation of
912/// `devflow-sign-probe-*` directories in `/tmp`.
913struct ProbeWorkspace(PathBuf);
914
915impl Drop for ProbeWorkspace {
916 fn drop(&mut self) {
917 let _ = std::fs::remove_dir_all(&self.0);
918 }
919}
920
921/// Sign throwaway bytes with the configured key and report only how that
922/// went. Creates a private workspace, runs the probe inside it, and removes
923/// the workspace on every exit path — including the timeout path, since
924/// `ssh-keygen -Y sign` writes its signature as a sibling of the payload
925/// (T-35-13: both live and die inside this one directory) — and including an
926/// unwind, via [`ProbeWorkspace`]'s `Drop`.
927fn run_ssh_sign_probe(key_path: &Path) -> SignProbeOutcome {
928 let workspace = std::env::temp_dir().join(probe_workspace_name());
929
930 // Non-recursive on purpose (T-35-12). `DirBuilder::create` FAILS when the
931 // path already exists — `recursive(true)` is never set — so a pre-planted
932 // directory or symlink cannot redirect where the payload is written;
933 // `create_dir_all` would accept one silently. `tempfile` is a
934 // dev-dependency of this crate and is unavailable to production code, and
935 // no dependency may be added, so this is `std` only.
936 //
937 // WR-07: mode 0o700, so "private" is implemented rather than merely
938 // claimed. `std::fs::create_dir` applies `0o777 & !umask` — typically
939 // 0o755, world-readable and world-traversable inside a shared
940 // `std::env::temp_dir()`. Nothing secret lands here (the payload is fixed
941 // bytes and `payload.sig` signs those bytes), so this was not an exposure
942 // of key material; it is that a future author extending the probe would
943 // read the comment rather than the mode bits.
944 if !create_probe_workspace(&workspace) {
945 return SignProbeOutcome::NotRun;
946 }
947 let _cleanup = ProbeWorkspace(workspace.clone());
948
949 sign_probe_within(&workspace, key_path)
950}
951
952/// Create the probe's workspace directory, owner-only and non-recursively.
953/// `false` means it could not be created — including because something was
954/// already there.
955///
956/// Split from [`run_ssh_sign_probe`] so both properties can be asserted on a
957/// directory that still exists: the probe removes its workspace before
958/// returning, so nothing downstream can inspect the mode bits (WR-07).
959fn create_probe_workspace(workspace: &Path) -> bool {
960 let mut builder = std::fs::DirBuilder::new();
961 #[cfg(unix)]
962 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
963 builder.create(workspace).is_ok()
964}
965
966/// The probe proper, with `workspace` already created and guaranteed to be
967/// removed by the caller.
968fn sign_probe_within(workspace: &Path, key_path: &Path) -> SignProbeOutcome {
969 // Bytes the probe generated itself, inside its own private directory
970 // (T-35-13). A viability check must never become an unauthorised
971 // signing operation over real content, so nothing from the operator's
972 // working tree is ever signed or even read here.
973 let payload = workspace.join("payload");
974 if std::fs::write(&payload, b"devflow signing viability probe\n").is_err() {
975 return SignProbeOutcome::NotRun;
976 }
977
978 let (Some(key_arg), Some(payload_arg)) = (key_path.to_str(), payload.to_str()) else {
979 return SignProbeOutcome::NotRun;
980 };
981
982 let mut command = Command::new("ssh-keygen");
983 command
984 .args([
985 "-Y",
986 "sign",
987 "-n",
988 SSH_SIGN_NAMESPACE,
989 "-f",
990 key_arg,
991 payload_arg,
992 ])
993 // D-01: closes the ASKPASS route, so an encrypted key cannot park an
994 // unattended preflight on an askpass helper. It does NOT close the
995 // /dev/tty route — see the `setsid` call below.
996 .env("SSH_ASKPASS_REQUIRE", "never")
997 .stdin(Stdio::null())
998 .stdout(Stdio::null())
999 // The child's stderr is discarded here and read by nobody: it
1000 // embeds the configured key path verbatim (`Couldn't load public
1001 // key ./does-not-exist.pub`), so reproducing any part of it in a
1002 // reason string would violate D-08's redaction contract. The exit
1003 // code is the sole verdict (D-02).
1004 .stderr(Stdio::null());
1005
1006 // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is the
1007 // only requirement `pre_exec` imposes.
1008 //
1009 // Detach from any controlling terminal before exec. `SSH_ASKPASS_REQUIRE
1010 // =never` alone is NOT sufficient: OpenSSH only consults it once
1011 // `open("/dev/tty")` has failed, so on a host that HAS a controlling
1012 // terminal `ssh-keygen` prompts for the passphrase on the terminal
1013 // regardless of the variable and blocks there until the ceiling expires.
1014 // Measured on this host with a real pty: 10.06s (i.e. the whole
1015 // SSH_SIGN_PROBE_TIMEOUT) before the fix, 0.02s after. Dropping the
1016 // controlling terminal makes that `open` fail, which is the condition
1017 // the variable is gated on.
1018 //
1019 // The failure is ignored deliberately. `setsid` only fails when the
1020 // caller is already a process-group leader, which a freshly forked child
1021 // is not; and if it somehow did fail, the probe degrades to exactly the
1022 // pre-fix behaviour, which the wall-clock ceiling already bounds. Turning
1023 // it into a spawn error would be worse: `spawn` failure is classified as
1024 // absent tooling, so it would surface as a false "ssh-keygen not found".
1025 unsafe {
1026 std::os::unix::process::CommandExt::pre_exec(&mut command, || {
1027 libc::setsid();
1028 Ok(())
1029 });
1030 }
1031
1032 let mut child = match command.spawn() {
1033 Ok(child) => child,
1034 Err(_) => return SignProbeOutcome::ToolMissing,
1035 };
1036
1037 // Bounded wait, following `canary.rs`'s `reap` shape: poll until the
1038 // deadline, then kill and wait so no child is left behind.
1039 let deadline = Instant::now() + SSH_SIGN_PROBE_TIMEOUT;
1040 loop {
1041 match child.try_wait() {
1042 Ok(Some(status)) => {
1043 return if status.success() {
1044 SignProbeOutcome::Signed
1045 } else {
1046 SignProbeOutcome::Rejected
1047 };
1048 }
1049 Ok(None) => {}
1050 Err(_) => {
1051 // Could not poll: nothing was established about the key, so
1052 // reap the child and degrade rather than inventing a verdict.
1053 let _ = child.kill();
1054 let _ = child.wait();
1055 return SignProbeOutcome::NotRun;
1056 }
1057 }
1058 if Instant::now() >= deadline {
1059 break;
1060 }
1061 std::thread::sleep(SSH_SIGN_PROBE_POLL);
1062 }
1063 let _ = child.kill();
1064 let _ = child.wait();
1065 SignProbeOutcome::TimedOut
1066}
1067
1068/// `gpg.format == "ssh"` branch (Pattern 4): `user.signingkey` must be set.
1069/// Its value is classified by git's own prefix rules (D-01) into either an
1070/// inline key blob or a filesystem path; only a path value is required to
1071/// exist, and only a path value is probed (D-03).
1072///
1073/// Viability is then established by **performing the operation** — signing
1074/// throwaway bytes with `ssh-keygen -Y sign` — rather than by predicting it
1075/// from `ssh-add -l`. The predictor this replaced inferred viability from
1076/// agent membership, which is not a necessary condition for `git tag -s` to
1077/// succeed: an unencrypted private key sitting beside the configured public
1078/// key signs fine with no agent at all. That gap false-negatived live on
1079/// two separate release cuts (999.86). A probe cannot drift out of sync
1080/// with git's real behaviour because it has no independent behaviour.
1081///
1082/// The probe's exit code is the sole verdict (D-02) and its stderr is never
1083/// re-emitted; on success only the public key's `SHA256:` fingerprint is
1084/// reported, never the configured value in any form (D-08's redaction
1085/// contract, unchanged).
1086fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
1087 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
1088 return SigningViability::NotViable {
1089 reason: "gpg.format=ssh but user.signingkey is not set".into(),
1090 };
1091 };
1092
1093 // Mirrors `man git-config`'s user.signingKey precedence (D-01): key::
1094 // form, then deprecated raw ssh- form, else a path. Never stat a path
1095 // for a prefix-matched value (D-02). Classification runs BEFORE the
1096 // value is treated as a filesystem path, so an inline value never
1097 // reaches the `.exists()` check below.
1098 if inline_signing_key_blob(&signingkey).is_some() {
1099 // D-03/A-17: inline values are not probed at all. Probing one would
1100 // mean materialising the blob to a temp file — measured to work,
1101 // declined on surface cost. The operator gets no verdict here
1102 // rather than a wrong one.
1103 return SigningViability::Unknown {
1104 reason: "cannot verify signing viability — an inline user.signingkey is not probed"
1105 .into(),
1106 };
1107 }
1108
1109 // Path branch keeps today's early return, byte-for-byte (D-12): the
1110 // `.exists()` check runs first and a missing file still returns the
1111 // existing missing-key-file `NotViable` before anything is spawned.
1112 let key_path = Path::new(&signingkey);
1113 if !key_path.exists() {
1114 return SigningViability::NotViable {
1115 reason: "user.signingkey is set but the key file does not exist".into(),
1116 };
1117 }
1118
1119 sign_probe_verdict(run_ssh_sign_probe(key_path), key_path)
1120}
1121
1122/// The probe outcome → operator-facing verdict mapping, split out from
1123/// [`check_ssh_signing_viability`] so it can be asserted for every variant
1124/// without spawning anything (WR-01). Forcing a real `TimedOut` needs a
1125/// 10-second wedged `ssh-keygen`; the classification that was wrong is right
1126/// here, and this is the level it can be pinned at.
1127///
1128/// Fixed reason strings keyed by failure class (D-02) — none is composed
1129/// from `ssh-keygen`'s output, and none names the configured key, a path,
1130/// or any part of the child's stderr. Every fail-soft class keeps the
1131/// file's existing "cannot verify signing viability — " prefix.
1132fn sign_probe_verdict(outcome: SignProbeOutcome, key_path: &Path) -> SigningViability {
1133 match outcome {
1134 SignProbeOutcome::Signed => SigningViability::Viable {
1135 fingerprint: public_key_fingerprint(key_path),
1136 },
1137 SignProbeOutcome::Rejected => SigningViability::NotViable {
1138 reason: "the configured signing key could not sign a test payload".into(),
1139 },
1140 // WR-01 (35-REVIEW): a timeout is a MEASUREMENT failure, and this
1141 // file argues that twice already — `NotRun`'s doc comment ("an
1142 // infrastructure problem is not evidence about the key") and 20d/D-06
1143 // (an unavailable tool yields `Unknown`, never a hard-fail
1144 // `NotViable`). D-01's own justification for the ceiling names a
1145 // wedged `ssh-agent` and a stalled PKCS11 provider; both are
1146 // infrastructure, and neither says anything about whether the key
1147 // signs. A FIDO/`sk-` key is the concrete case: `ssh-keygen -Y sign`
1148 // waits for a physical touch, the prompt reaches nobody (stdio is
1149 // nulled and `setsid` dropped the terminal), and ten seconds later a
1150 // key that `git tag -s` signs with fine would have hard-failed a
1151 // release cut — the 999.86 defect class reintroduced by the
1152 // replacement.
1153 SignProbeOutcome::TimedOut => SigningViability::Unknown {
1154 reason: "cannot verify signing viability — the signing probe did not finish \
1155 within its time limit"
1156 .into(),
1157 },
1158 SignProbeOutcome::ToolMissing => SigningViability::Unknown {
1159 reason: "cannot verify signing viability — ssh-keygen not found".into(),
1160 },
1161 SignProbeOutcome::NotRun => SigningViability::Unknown {
1162 reason: "cannot verify signing viability — the signing probe could not be run".into(),
1163 },
1164 }
1165}
1166
1167/// `gpg.format` unset or `"openpgp"` branch (Pattern 4): verify a secret
1168/// key exists for `user.signingkey` via `gpg --list-secret-keys`.
1169fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
1170 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
1171 return SigningViability::Unknown {
1172 reason: "cannot verify signing viability — user.signingkey is not set".into(),
1173 };
1174 };
1175 let output = match Command::new("gpg")
1176 .args(["--list-secret-keys", &signingkey])
1177 .output()
1178 {
1179 Ok(out) => out,
1180 Err(_) => {
1181 return SigningViability::Unknown {
1182 reason: "cannot verify signing viability — gpg not found".into(),
1183 };
1184 }
1185 };
1186 if output.status.success() {
1187 SigningViability::Viable {
1188 fingerprint: Some(signingkey),
1189 }
1190 } else {
1191 SigningViability::NotViable {
1192 reason: "no secret key found for the configured user.signingkey".into(),
1193 }
1194 }
1195}
1196
1197/// Tag-signing viability check (20d): branches on `git config gpg.format`
1198/// since the check is a genuinely different code path per format — a
1199/// GPG-only check would miss the `ssh_askpass` failure this project's own
1200/// release actually hit (Pattern 4). Fail-soft throughout: an absent tool
1201/// or unset config degrades to an actionable [`SigningViability::Unknown`],
1202/// never a crash.
1203pub fn check_signing_viability(project_root: &Path) -> SigningViability {
1204 match git_config(project_root, "gpg.format").as_deref() {
1205 Some("ssh") => check_ssh_signing_viability(project_root),
1206 _ => check_gpg_signing_viability(project_root),
1207 }
1208}
1209
1210fn stderr_or_status(output: &std::process::Output) -> String {
1211 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1212 if stderr.is_empty() {
1213 format!("exited with {}", output.status)
1214 } else {
1215 stderr
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use tempfile::TempDir;
1223
1224 /// Run a git command in `root`, asserting success.
1225 fn git(root: &Path, args: &[&str]) {
1226 let output = crate::test_support::git_command(root)
1227 .args(args)
1228 .output()
1229 .expect("spawn git");
1230 assert!(
1231 output.status.success(),
1232 "git {args:?} failed: {}",
1233 String::from_utf8_lossy(&output.stderr)
1234 );
1235 }
1236
1237 fn current_branch(root: &Path) -> String {
1238 let output = crate::test_support::git_command(root)
1239 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1240 .output()
1241 .expect("rev-parse");
1242 String::from_utf8_lossy(&output.stdout).trim().to_string()
1243 }
1244
1245 fn commit_file(root: &Path, name: &str) {
1246 std::fs::write(root.join(name), name).unwrap();
1247 git(root, &["add", "."]);
1248 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1249 }
1250
1251 /// Initialize a repo with `main` and `develop` branches and one commit.
1252 fn init_repo() -> TempDir {
1253 let dir = tempfile::tempdir().unwrap();
1254 let root = dir.path();
1255 git(root, &["init", "-q"]);
1256 git(root, &["config", "user.email", "test@example.com"]);
1257 git(root, &["config", "user.name", "Test"]);
1258 git(root, &["config", "commit.gpgsign", "false"]);
1259 git(root, &["config", "tag.gpgsign", "false"]);
1260 // Disable any globally-configured hooks (e.g. gitleaks) for isolation.
1261 git(root, &["config", "core.hooksPath", "/dev/null"]);
1262 commit_file(root, "README.md");
1263 git(root, &["branch", "-M", "main"]);
1264 git(root, &["checkout", "-q", "-b", "develop"]);
1265 dir
1266 }
1267
1268 fn flow(root: &Path) -> GitFlow {
1269 GitFlow::new(root)
1270 }
1271
1272 #[test]
1273 fn feature_start_branches_from_develop() {
1274 let repo = init_repo();
1275 let root = repo.path();
1276 let branch = flow(root)
1277 .feature_start(PhaseId::new(3))
1278 .expect("feature_start");
1279 assert_eq!(branch, "feature/phase-03");
1280 assert_eq!(current_branch(root), "feature/phase-03");
1281 }
1282
1283 #[test]
1284 fn list_feature_branches_reports_ahead_and_behind_semantics() {
1285 let repo = init_repo();
1286 let root = repo.path();
1287 let gf = flow(root);
1288
1289 gf.feature_start(PhaseId::new(12)).expect("feature_start");
1290 commit_file(root, "feature-one.txt");
1291 commit_file(root, "feature-two.txt");
1292 git(root, &["checkout", "-q", "develop"]);
1293 commit_file(root, "develop-only.txt");
1294
1295 let branches = gf.list_feature_branches().unwrap();
1296 let branch = branches
1297 .iter()
1298 .find(|branch| branch.name == "feature/phase-12")
1299 .unwrap();
1300
1301 assert_eq!(branch.ahead, 2);
1302 assert_eq!(branch.behind, 1);
1303 }
1304
1305 #[test]
1306 fn feature_finish_merges_into_develop_and_deletes() {
1307 let repo = init_repo();
1308 let root = repo.path();
1309 let gf = flow(root);
1310
1311 gf.feature_start(PhaseId::new(1)).expect("start");
1312 commit_file(root, "feature.txt");
1313
1314 let branch = gf.feature_finish(PhaseId::new(1)).expect("finish");
1315 assert_eq!(branch, "feature/phase-01");
1316 assert_eq!(current_branch(root), "develop");
1317
1318 // Branch is deleted and its work is now on develop.
1319 let branches = crate::test_support::git_command(root)
1320 .args(["branch"])
1321 .output()
1322 .unwrap();
1323 let listing = String::from_utf8_lossy(&branches.stdout);
1324 assert!(!listing.contains("feature/phase-01"));
1325 assert!(root.join("feature.txt").exists());
1326 }
1327
1328 #[test]
1329 fn release_start_and_finish_tags_main_and_merges_both() {
1330 let repo = init_repo();
1331 let root = repo.path();
1332 let gf = flow(root);
1333
1334 // Add work on develop so the release has content.
1335 commit_file(root, "work.txt");
1336 let branch = gf.release_start("1.2.0").expect("release_start");
1337 assert_eq!(branch, "release/1.2.0");
1338
1339 gf.release_finish("1.2.0").expect("release_finish");
1340 assert_eq!(current_branch(root), "develop");
1341
1342 // Tag exists.
1343 let tags = crate::test_support::git_command(root)
1344 .args(["tag"])
1345 .output()
1346 .unwrap();
1347 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1348
1349 // Release branch deleted.
1350 let branches = crate::test_support::git_command(root)
1351 .args(["branch"])
1352 .output()
1353 .unwrap();
1354 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1355 }
1356
1357 /// A global/repo `tag.gpgsign=true` must not turn `tag()`'s lightweight
1358 /// tag into an annotated+signed one — that would require a tag message
1359 /// and block on `$EDITOR`, silently hanging a headless, unattended run
1360 /// (Phase 13 dogfood finding: VersionBump hung on a live
1361 /// `devflow start --mode auto` run because the operator's global
1362 /// gitconfig sets `tag.gpgsign=true`).
1363 #[test]
1364 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1365 let repo = init_repo();
1366 let root = repo.path();
1367 // Simulate an operator whose global config signs tags by default —
1368 // override the test harness's own `tag.gpgsign false` to prove
1369 // `tag()`'s per-invocation `-c` override wins regardless.
1370 git(root, &["config", "tag.gpgsign", "true"]);
1371
1372 flow(root)
1373 .tag("v9.9.9")
1374 .expect("tag must not block on $EDITOR");
1375
1376 let tags = crate::test_support::git_command(root)
1377 .args(["tag", "-l"])
1378 .output()
1379 .unwrap();
1380 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1381
1382 // Confirm it's a lightweight tag (points directly at the commit),
1383 // not an annotated tag object (which `cat-file -t` would report as
1384 // "tag" rather than "commit").
1385 let obj_type = crate::test_support::git_command(root)
1386 .args(["cat-file", "-t", "v9.9.9"])
1387 .output()
1388 .unwrap();
1389 assert_eq!(
1390 String::from_utf8_lossy(&obj_type.stdout).trim(),
1391 "commit",
1392 "tag() must stay lightweight even when tag.gpgsign=true"
1393 );
1394 }
1395
1396 #[test]
1397 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1398 // The property that distinguishes commit_path from commit_all
1399 // (17-12, Task 2b): a hook using commit_path must never sweep in
1400 // unrelated dirty state.
1401 let repo = init_repo();
1402 let root = repo.path();
1403 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1404 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1405
1406 // Stage the unrelated file BEFORE calling commit_path. An untracked
1407 // file is excluded by any implementation and so proves nothing; an
1408 // already-staged one is the real failure mode — a bare `git commit`
1409 // writes the whole index and would sweep it in.
1410 crate::test_support::git_command(root)
1411 .args(["add", "unrelated.txt"])
1412 .status()
1413 .unwrap();
1414
1415 flow(root)
1416 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1417 .expect("commit_path");
1418
1419 let committed = crate::test_support::git_command(root)
1420 .args(["log", "-1", "--name-only", "--pretty=format:"])
1421 .output()
1422 .unwrap();
1423 let committed_files = String::from_utf8_lossy(&committed.stdout);
1424 assert!(committed_files.contains("CHANGELOG.md"));
1425 assert!(!committed_files.contains("unrelated.txt"));
1426
1427 let status = crate::test_support::git_command(root)
1428 .args(["status", "--porcelain"])
1429 .output()
1430 .unwrap();
1431 let status = String::from_utf8_lossy(&status.stdout);
1432 assert!(
1433 status.contains("A unrelated.txt"),
1434 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1435 );
1436 }
1437
1438 /// `git rev-list --count HEAD`, parsed. Shared by the three tests below
1439 /// so a failure reports both counts instead of a bare assertion.
1440 fn rev_list_count(root: &Path) -> u32 {
1441 let output = crate::test_support::git_command(root)
1442 .args(["rev-list", "--count", "HEAD"])
1443 .output()
1444 .unwrap();
1445 assert!(output.status.success(), "git rev-list --count HEAD failed");
1446 String::from_utf8_lossy(&output.stdout)
1447 .trim()
1448 .parse::<u32>()
1449 .expect("rev-list --count HEAD must print an integer")
1450 }
1451
1452 /// 19b/D-16: `hooks::version_bump` (hooks.rs:242) calls `commit_path` and
1453 /// then tags whatever commit it last produced (hooks.rs:249). If a
1454 /// terminal-batch retry calls `commit_path` again with byte-identical
1455 /// content (the file untouched since the first call), a forced commit
1456 /// here means the release tag can end up naming a commit that contains
1457 /// nothing new. This pins the exact retry scenario: two calls, unchanged
1458 /// content, `git rev-list --count HEAD` must not move between them.
1459 #[test]
1460 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1461 let repo = init_repo();
1462 let root = repo.path();
1463 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1464
1465 flow(root)
1466 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1467 .expect("first commit_path call");
1468 let n1 = rev_list_count(root);
1469
1470 // The file is not touched again -- this is the retry scenario, not
1471 // a second genuine change.
1472 flow(root)
1473 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1474 .expect("second commit_path call");
1475 let n2 = rev_list_count(root);
1476
1477 assert_eq!(
1478 n2, n1,
1479 "a repeat commit_path call on unchanged content must not add a \
1480 commit: n1={n1}, n2={n2}"
1481 );
1482 }
1483
1484 /// 19b/D-16, T-19-11: separates the "no commit" claim from the "no
1485 /// error" claim so a future change can't satisfy one by breaking the
1486 /// other. `hooks.rs` propagates `commit_path`'s `Result` with `?` at both
1487 /// call sites (changelog_append:225, version_bump:242) -- turning a
1488 /// genuine no-op into `Err` would stall the terminal hook batch (see
1489 /// T-19-11 in this plan's threat model), so both properties must hold
1490 /// simultaneously.
1491 #[test]
1492 fn commit_path_with_no_changes_returns_ok_without_committing() {
1493 let repo = init_repo();
1494 let root = repo.path();
1495 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1496 flow(root)
1497 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1498 .expect("initial commit_path");
1499 let n1 = rev_list_count(root);
1500
1501 // CHANGELOG.md is already committed and unmodified -- a single call
1502 // here has nothing to commit.
1503 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1504 let n2 = rev_list_count(root);
1505
1506 assert!(
1507 result.is_ok(),
1508 "no-op call must return Ok(()), got: {result:?}"
1509 );
1510 assert_eq!(
1511 n2, n1,
1512 "no-op call must not create a commit: n1={n1}, n2={n2}"
1513 );
1514 }
1515
1516 /// Edge case the fix must NOT change: `commit_path` on a path that does
1517 /// not exist on disk still errors at the staging step (`git add` fails
1518 /// on an unknown pathspec). Asserted explicitly so the fix for the
1519 /// no-change case above cannot be over-applied into "commit_path never
1520 /// fails".
1521 #[test]
1522 fn commit_path_on_nonexistent_path_still_errors() {
1523 let repo = init_repo();
1524 let root = repo.path();
1525
1526 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1527
1528 assert!(
1529 result.is_err(),
1530 "commit_path on an unknown pathspec must still error, got: {result:?}"
1531 );
1532 }
1533
1534 #[test]
1535 fn release_start_branches_from_current_head_not_develop() {
1536 let repo = init_repo();
1537 let root = repo.path();
1538 let gf = flow(root);
1539
1540 // Ship from a feature branch carrying a commit that is NOT on develop.
1541 gf.feature_start(PhaseId::new(5)).expect("feature_start");
1542 commit_file(root, "feature-only.txt");
1543 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1544
1545 let branch = gf.release_start("2.0.0").expect("release_start");
1546 assert_eq!(branch, "release/2.0.0");
1547 assert_eq!(current_branch(root), "release/2.0.0");
1548
1549 // The release branch tip must descend from the feature commit — i.e.
1550 // the feature-only work is present, not dropped to develop's HEAD.
1551 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1552 let is_ancestor = crate::test_support::git_command(root)
1553 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1554 .output()
1555 .unwrap()
1556 .status
1557 .success();
1558 assert!(
1559 is_ancestor,
1560 "release branch must descend from the shipped feature commit"
1561 );
1562 assert!(root.join("feature-only.txt").exists());
1563 }
1564
1565 #[test]
1566 fn cleanup_merged_removes_merged_but_keeps_protected() {
1567 let repo = init_repo();
1568 let root = repo.path();
1569 let gf = flow(root);
1570
1571 // Create and merge a feature branch into develop.
1572 gf.feature_start(PhaseId::new(2)).expect("start");
1573 commit_file(root, "f.txt");
1574 gf.feature_finish(PhaseId::new(2)).expect("finish");
1575
1576 // Create an already-merged stray branch off develop.
1577 git(root, &["branch", "stale-merged"]);
1578
1579 let deleted = gf.cleanup_merged().expect("cleanup");
1580 assert!(deleted.contains(&"stale-merged".to_string()));
1581 // Protected branches survive.
1582 assert!(!deleted.contains(&"develop".to_string()));
1583 assert!(!deleted.contains(&"main".to_string()));
1584 }
1585
1586 /// WR-04 (13-REVIEW.md): `cleanup_merged` must compute "merged" relative
1587 /// to `develop` explicitly, not whatever the main checkout's current
1588 /// HEAD happens to be. If the main checkout is left on a divergent
1589 /// branch, an implicit-HEAD baseline would wrongly identify (and
1590 /// delete) a branch that's merged into that other branch but was never
1591 /// actually merged into `develop`.
1592 #[test]
1593 fn cleanup_merged_is_relative_to_develop_not_current_head() {
1594 let repo = init_repo();
1595 let root = repo.path();
1596 let gf = flow(root);
1597
1598 // `topic` diverges from develop with a unique commit develop never
1599 // sees, then `premature` branches off `topic`'s tip — so
1600 // `premature` is merged into `topic` but NOT into `develop`.
1601 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1602 commit_file(root, "topic-only.txt");
1603 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1604
1605 // Leave the main checkout on `topic` — NOT `develop` — before
1606 // calling cleanup_merged, mirroring an operator who forgot to
1607 // check out develop first. (`topic` itself is also technically
1608 // "merged into HEAD" under an implicit baseline since it IS HEAD,
1609 // which git's own `-d` correctly refuses as the checked-out branch
1610 // — so the call's overall Ok/Err is not itself decisive here; check
1611 // the actual side effect on `premature` instead.)
1612 git(root, &["checkout", "-q", "topic"]);
1613
1614 let _ = gf.cleanup_merged();
1615 assert!(
1616 gf.branch_exists("premature"),
1617 "premature is merged into topic (current HEAD) but not into \
1618 develop — it must survive cleanup_merged when the baseline is develop"
1619 );
1620 }
1621
1622 /// WR-03 (13-REVIEW.md), revised: `git branch --merged` prefixes a
1623 /// branch checked out in a linked worktree with `+ `. The prefix must be
1624 /// stripped positionally (not by trimming marker characters, which would
1625 /// mangle a branch legitimately named "+foo"), and a branch git refuses
1626 /// to delete — a worktree checkout can never be deleted, by design —
1627 /// must be skipped with a warning rather than aborting the sweep before
1628 /// the remaining merged branches.
1629 #[test]
1630 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1631 let repo = init_repo();
1632 let root = repo.path();
1633 let gf = flow(root);
1634
1635 // Merge a branch into develop WITHOUT deleting it (feature_finish
1636 // deletes on merge, which would leave nothing to check out).
1637 git(
1638 root,
1639 &["checkout", "-q", "-b", "worktree-merged", "develop"],
1640 );
1641 commit_file(root, "g.txt");
1642 git(root, &["checkout", "-q", "develop"]);
1643 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1644
1645 // Check the merged branch out in a linked worktree so
1646 // `git branch --merged` reports it with a `+ ` prefix.
1647 let wt_dir = tempfile::tempdir().unwrap();
1648 git(
1649 root,
1650 &[
1651 "worktree",
1652 "add",
1653 wt_dir.path().to_str().unwrap(),
1654 "worktree-merged",
1655 ],
1656 );
1657
1658 // A second merged branch that sorts after "worktree-merged" would be
1659 // reached only if the sweep survives the worktree refusal; "zz-" also
1660 // guards against luck in iteration order via the branch before it.
1661 git(root, &["branch", "aa-stale"]);
1662 git(root, &["branch", "zz-stale"]);
1663
1664 let deleted = gf
1665 .cleanup_merged()
1666 .expect("a skipped worktree branch must not abort the sweep");
1667 assert!(deleted.contains(&"aa-stale".to_string()));
1668 assert!(deleted.contains(&"zz-stale".to_string()));
1669 assert!(
1670 !deleted.contains(&"worktree-merged".to_string()),
1671 "worktree checkout cannot be deleted"
1672 );
1673 assert!(gf.branch_exists("worktree-merged"));
1674 }
1675
1676 /// The delete side must agree with the `--merged develop` listing: `-d`
1677 /// verifies merged-into-HEAD, so with the main checkout parked on a
1678 /// stale branch every genuinely-merged branch was refused as "not fully
1679 /// merged" — in exactly the scenario WR-04 exists for.
1680 #[test]
1681 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1682 let repo = init_repo();
1683 let root = repo.path();
1684 let gf = flow(root);
1685
1686 // `old` is parked before the merge below, so nothing merged later is
1687 // reachable from HEAD while it's checked out.
1688 git(root, &["checkout", "-q", "-b", "old", "develop"]);
1689 git(root, &["checkout", "-q", "develop"]);
1690 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1691 commit_file(root, "h.txt");
1692 git(root, &["checkout", "-q", "develop"]);
1693 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1694 git(root, &["checkout", "-q", "old"]);
1695
1696 let deleted = gf.cleanup_merged().expect("cleanup");
1697 assert!(
1698 deleted.contains(&"merged-feature".to_string()),
1699 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1700 );
1701 assert!(!gf.branch_exists("merged-feature"));
1702 }
1703
1704 #[test]
1705 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1706 let repo = init_repo();
1707 let root = repo.path();
1708 let gf = flow(root);
1709
1710 // Create a feature branch with an unmerged commit.
1711 gf.feature_start(PhaseId::new(8)).expect("start");
1712 commit_file(root, "unmerged.txt");
1713 // Switch back to develop so the branch isn't checked out.
1714 git(root, &["checkout", "-q", "develop"]);
1715
1716 // -d would refuse (unmerged); force deletes it.
1717 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1718 gf.delete_branch("feature/phase-08", true)
1719 .expect("force delete");
1720 let branches = crate::test_support::git_command(root)
1721 .args(["branch"])
1722 .output()
1723 .unwrap();
1724 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1725
1726 // Protected branches are never deleted.
1727 assert!(gf.delete_branch("develop", true).is_err());
1728 assert!(gf.delete_branch("main", true).is_err());
1729 }
1730
1731 #[test]
1732 fn merge_of_missing_branch_is_an_error() {
1733 let repo = init_repo();
1734 let root = repo.path();
1735 // feature_finish for a phase that was never started: checkout develop
1736 // succeeds, but merging the nonexistent feature branch fails.
1737 let err = flow(root).feature_finish(PhaseId::new(99)).unwrap_err();
1738 assert!(matches!(err, GitError::Command(_)));
1739 }
1740
1741 // -----------------------------------------------------------------
1742 // 20d: publish-order helpers (pure, no I/O)
1743 // -----------------------------------------------------------------
1744
1745 #[test]
1746 fn workspace_member_paths_parses_multiline_array() {
1747 let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
1748 assert_eq!(
1749 workspace_member_paths(contents),
1750 vec![
1751 "crates/devflow-core".to_string(),
1752 "crates/devflow-cli".to_string()
1753 ]
1754 );
1755 }
1756
1757 #[test]
1758 fn package_name_reads_the_package_section() {
1759 let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1760 assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1761 }
1762
1763 #[test]
1764 fn member_depends_on_matches_dotted_workspace_shorthand() {
1765 let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1766 assert!(member_depends_on(contents, "devflow-core"));
1767 assert!(!member_depends_on(contents, "serde"));
1768 }
1769
1770 /// WR-03 (phase 20 review): the equally-valid expanded long-form TOML
1771 /// section syntax (`[dependencies.NAME]`) parses to a section header of
1772 /// `"dependencies.NAME"`, never equal to the plain `"dependencies"` the
1773 /// inline-table branch checks against — this must still be recognized
1774 /// as a dependency edge.
1775 #[test]
1776 fn member_depends_on_matches_long_form_dependency_section() {
1777 let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1778 assert!(member_depends_on(contents, "devflow-core"));
1779 assert!(member_depends_on(contents, "clap"));
1780 assert!(!member_depends_on(contents, "serde"));
1781 }
1782
1783 #[test]
1784 fn topo_sort_orders_dependency_before_dependent() {
1785 let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1786 let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1787 assert_eq!(
1788 topo_sort(names, edges),
1789 vec!["devflow-core".to_string(), "devflow".to_string()]
1790 );
1791 }
1792
1793 #[test]
1794 fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1795 // A genuine cyclic dependency would already fail `cargo build`
1796 // long before this check runs — this just proves no infinite loop.
1797 let names = vec!["a".to_string(), "b".to_string()];
1798 let edges = vec![
1799 ("a".to_string(), "b".to_string()),
1800 ("b".to_string(), "a".to_string()),
1801 ];
1802 let result = topo_sort(names, edges);
1803 assert_eq!(result.len(), 2);
1804 }
1805
1806 #[test]
1807 fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1808 let dir = tempfile::tempdir().unwrap();
1809 let root = dir.path();
1810 std::fs::write(
1811 root.join("Cargo.toml"),
1812 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1813 )
1814 .unwrap();
1815 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1816 std::fs::write(
1817 root.join("crates/devflow-core/Cargo.toml"),
1818 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1819 )
1820 .unwrap();
1821 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1822 std::fs::write(
1823 root.join("crates/devflow-cli/Cargo.toml"),
1824 "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1825 )
1826 .unwrap();
1827
1828 assert_eq!(
1829 publish_order(root),
1830 vec!["devflow-core".to_string(), "devflow".to_string()]
1831 );
1832 }
1833
1834 /// WR-03 (phase 20 review): a workspace member manifest written with
1835 /// the long-form `[dependencies.devflow-core]` section (rather than the
1836 /// inline `[dependencies]\ndevflow-core.workspace = true` form) must
1837 /// still contribute its dependency edge to `publish_order`'s topo-sort
1838 /// — the release-safety-critical crates.io publish order this
1839 /// self-pin regression would otherwise silently get wrong.
1840 #[test]
1841 fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1842 let dir = tempfile::tempdir().unwrap();
1843 let root = dir.path();
1844 std::fs::write(
1845 root.join("Cargo.toml"),
1846 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1847 )
1848 .unwrap();
1849 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1850 std::fs::write(
1851 root.join("crates/devflow-core/Cargo.toml"),
1852 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1853 )
1854 .unwrap();
1855 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1856 std::fs::write(
1857 root.join("crates/devflow-cli/Cargo.toml"),
1858 "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1859 )
1860 .unwrap();
1861
1862 assert_eq!(
1863 publish_order(root),
1864 vec!["devflow-core".to_string(), "devflow".to_string()],
1865 "the long-form dependency section must still order devflow-core before devflow"
1866 );
1867 }
1868
1869 // -----------------------------------------------------------------
1870 // 20d: origin/main ancestor check (no fetch)
1871 // -----------------------------------------------------------------
1872
1873 #[test]
1874 fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1875 let repo = init_repo();
1876 let root = repo.path();
1877 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1878 }
1879
1880 #[test]
1881 fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1882 let repo = init_repo();
1883 let root = repo.path();
1884 let head = crate::test_support::git_command(root)
1885 .args(["rev-parse", "HEAD"])
1886 .output()
1887 .unwrap();
1888 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1889 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1890 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1891 }
1892
1893 // -----------------------------------------------------------------
1894 // 27-01 (D-03): the scrubbing constructor holds under a hostile GIT_DIR
1895 // -----------------------------------------------------------------
1896
1897 /// D-03: a real spawned `git` process built through the constructor
1898 /// resolves the caller-supplied root even when `GIT_DIR` points at an
1899 /// unrelated repository — proven by a subprocess test, not by
1900 /// inspecting the `Command` object alone.
1901 #[test]
1902 fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1903 let real_repo = init_repo();
1904 let real_root = real_repo.path();
1905
1906 let foreign_repo = TempDir::new().unwrap();
1907 git(foreign_repo.path(), &["init", "-q"]);
1908
1909 let output = git_command(real_root)
1910 .args(["rev-parse", "--show-toplevel"])
1911 // Hostile injection chained AFTER the constructor — the
1912 // strongest form of the claim: `--show-toplevel` must still
1913 // resolve `real_root`, not `foreign_repo`.
1914 .env("GIT_DIR", foreign_repo.path().join(".git"))
1915 .output()
1916 .expect("spawn git");
1917 assert!(
1918 output.status.success(),
1919 "rev-parse --show-toplevel failed: {}",
1920 String::from_utf8_lossy(&output.stderr)
1921 );
1922
1923 let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1924 .expect("canonicalize resolved toplevel");
1925 let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1926 assert_eq!(
1927 resolved, expected,
1928 "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1929 );
1930 }
1931
1932 /// D-03: `origin_main_ancestor_status` produces the correct answer
1933 /// under a hostile `GIT_DIR` where it previously did not. Setting a
1934 /// process-global env var is forbidden (Rust 2024 `unsafe`, unsound
1935 /// under threaded tests — Phase 25 D-14), so this proves the property
1936 /// the way the constructor guarantees it, in two parts: (a) the
1937 /// `Command` this code path builds via `git_command` is
1938 /// unconditionally scrubbed — no bypass parameter, no env-var check,
1939 /// no config lookup (D-01), asserted directly on the built `Command`;
1940 /// (b) the actual mechanism `origin_main_ancestor_status` now depends
1941 /// on — scrubbed, with nothing in production code re-adding `GIT_DIR`
1942 /// afterward — reaches the correct answer for a real spawn. (A literal
1943 /// unscrubbed `Command::new("git")` reproduction chaining a hostile
1944 /// `.env("GIT_DIR", foreign)` on top was deliberately NOT added here:
1945 /// verified empirically against this machine's git 2.55.0 that doing
1946 /// so genuinely redirects `merge-base --is-ancestor`'s ref resolution
1947 /// to the foreign repo — unlike `--show-toplevel` above, which falls
1948 /// back to cwd when `GIT_WORK_TREE` is unset — so re-adding it here
1949 /// would both prove nothing new beyond (a) and inflate git.rs's
1950 /// unscrubbed-call-site count past the 7 sites this task deliberately
1951 /// leaves for 27-02.)
1952 #[test]
1953 fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1954 let repo = init_repo();
1955 let root = repo.path();
1956 let head = crate::test_support::git_command(root)
1957 .args(["rev-parse", "HEAD"])
1958 .output()
1959 .unwrap();
1960 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1961 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1962
1963 // (a) unconditionally scrubbed.
1964 let cmd = git_command(root);
1965 assert!(
1966 cmd.get_envs()
1967 .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1968 "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1969 );
1970
1971 // (b) the actual, scrubbed mechanism reaches the correct answer.
1972 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1973 }
1974
1975 // -----------------------------------------------------------------
1976 // 27-01: hermetic git command construction (moved from test_support,
1977 // now the canonical, always-compiled home — 999.37/999.39/27-01)
1978 // -----------------------------------------------------------------
1979
1980 /// The contract callers depend on, asserted on the built command rather
1981 /// than inferred: every redirecting variable is marked for removal.
1982 #[test]
1983 fn git_command_marks_every_redirecting_var_for_removal() {
1984 let cmd = git_command(Path::new("/tmp"));
1985 let removed: Vec<&str> = cmd
1986 .get_envs()
1987 .filter(|(_, value)| value.is_none())
1988 .filter_map(|(key, _)| key.to_str())
1989 .collect();
1990
1991 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1992 assert!(
1993 removed.contains(var),
1994 "{var} is not cleared by git_command — a fixture inheriting it \
1995 would operate on that repository instead of its tempdir"
1996 );
1997 }
1998 }
1999
2000 /// GIT_EXEC_PATH must survive: clearing it can break git's own helper
2001 /// lookup on installations that rely on it, and it cannot redirect
2002 /// repository resolution.
2003 #[test]
2004 fn git_command_preserves_git_exec_path() {
2005 let cmd = git_command(Path::new("/tmp"));
2006 assert!(
2007 !cmd.get_envs()
2008 .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
2009 "GIT_EXEC_PATH must not be cleared"
2010 );
2011 }
2012
2013 /// Guards the hard-coded list against a git upgrade that adds a
2014 /// repository-local variable. If this fails, add the new name to
2015 /// `REPO_LOCAL_GIT_VARS` — do not delete the assertion.
2016 #[test]
2017 fn local_env_vars_match_git() {
2018 let output = git_command(Path::new("/tmp"))
2019 .args(["rev-parse", "--local-env-vars"])
2020 .output()
2021 .expect("run `git rev-parse --local-env-vars`");
2022 assert!(
2023 output.status.success(),
2024 "`git rev-parse --local-env-vars` failed"
2025 );
2026
2027 let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
2028 .lines()
2029 .map(str::trim)
2030 .filter(|line| !line.is_empty())
2031 .map(str::to_string)
2032 .collect();
2033 let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
2034 .iter()
2035 .map(|v| (*v).to_string())
2036 .collect();
2037 from_git.sort();
2038 ours.sort();
2039
2040 assert_eq!(
2041 ours, from_git,
2042 "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
2043 );
2044 }
2045
2046 // -----------------------------------------------------------------
2047 // 20d: signing-viability helpers
2048 // -----------------------------------------------------------------
2049
2050 /// Guards tests that temporarily override the process-global `HOME`
2051 /// env var (same idiom as `config.rs`'s test-local `ENV_MUTEX`) — this
2052 /// project's own dev machine sets `gpg.format=ssh` / `user.signingkey`
2053 /// GLOBALLY (the exact Pattern 4 research finding), so a hermetic test
2054 /// of the "unset" branch must isolate `$HOME/.gitconfig`, not just the
2055 /// repo-local config.
2056 static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2057
2058 #[test]
2059 fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
2060 // 20d/empty: no gpg.format, no user.signingkey — must degrade to an
2061 // actionable message, never panic.
2062 let _lock = HOME_ENV_MUTEX.lock().unwrap();
2063 let repo = init_repo();
2064 let root = repo.path();
2065 let fake_home = tempfile::tempdir().unwrap();
2066 let original_home = std::env::var_os("HOME");
2067 // SAFETY: serialized under HOME_ENV_MUTEX; restored below before
2068 // the guard drops.
2069 unsafe { std::env::set_var("HOME", fake_home.path()) };
2070
2071 let result = check_signing_viability(root);
2072
2073 // SAFETY: still serialized under HOME_ENV_MUTEX.
2074 match original_home {
2075 Some(home) => unsafe { std::env::set_var("HOME", home) },
2076 None => unsafe { std::env::remove_var("HOME") },
2077 }
2078
2079 match result {
2080 SigningViability::Unknown { reason } => {
2081 assert!(
2082 reason.contains("user.signingkey"),
2083 "unexpected reason: {reason}"
2084 );
2085 }
2086 other => panic!("expected Unknown (fail-soft), got: {other:?}"),
2087 }
2088 }
2089
2090 /// D-01/D-02/D-10: an inline `user.signingkey` value — either the
2091 /// `key::`-prefixed form or the raw deprecated `ssh-` compat form — must
2092 /// never be classified as a missing filesystem path. Git never stats an
2093 /// inline value, so this must never return the missing-key-file
2094 /// `NotViable`.
2095 ///
2096 /// This test deliberately keeps its narrow assertion — only that the
2097 /// missing-file reason is absent. It used to be narrow because the
2098 /// outcome depended on the host's ssh-agent state (D-10); it is narrow
2099 /// now because that is the single property it exists to guard, and
2100 /// `inline_signing_key_returns_unknown_without_probing` pins the exact
2101 /// arm. Leaving the assertion here narrow keeps one falsifier per test.
2102 #[test]
2103 fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
2104 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
2105 let inline_values = [
2106 "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
2107 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
2108 ];
2109 for value in inline_values {
2110 let repo = init_repo();
2111 let root = repo.path();
2112 git(root, &["config", "gpg.format", "ssh"]);
2113 git(root, &["config", "user.signingkey", value]);
2114
2115 let result = check_signing_viability(root);
2116
2117 if let SigningViability::NotViable { reason } = &result {
2118 assert_ne!(
2119 reason, MISSING_FILE_REASON,
2120 "inline signingkey value {value:?} incorrectly classified as a \
2121 missing file: {result:?}"
2122 );
2123 }
2124 }
2125 }
2126
2127 /// D-01/D-02/D-03: a flat table over the pure classifier proving git's
2128 /// own prefix precedence — `key::` strip first, then the raw `ssh-`
2129 /// compat form, else a path. Non-`ssh-` algorithms (`ecdsa-`, `sk-`)
2130 /// reach the inline branch ONLY through `key::` (D-03) — a bare form of
2131 /// either is a path, matching git.
2132 #[test]
2133 fn inline_signing_key_blob_follows_git_prefix_precedence() {
2134 assert_eq!(
2135 inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
2136 Some("ssh-rsa AAAAB3 id")
2137 );
2138 assert_eq!(
2139 inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
2140 Some("ssh-ed25519 AAAAC3 id")
2141 );
2142 assert_eq!(
2143 inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
2144 Some("ecdsa-sha2-nistp256 AAAAE2 id")
2145 );
2146 assert_eq!(inline_signing_key_blob("key::"), Some(""));
2147 assert_eq!(
2148 inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
2149 Some("ssh-ed25519 AAAAC3 id")
2150 );
2151 assert_eq!(
2152 inline_signing_key_blob(" key::ssh-ed25519 AAAAC3 id "),
2153 Some("ssh-ed25519 AAAAC3 id")
2154 );
2155 // D-02: a value that plausibly names an existing file is STILL
2156 // inline, because the classifier never stats it.
2157 assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
2158 assert_eq!(
2159 inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
2160 None
2161 );
2162 // D-03: bare, no `key::` prefix, so git treats these as paths and so
2163 // must DevFlow.
2164 assert_eq!(
2165 inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
2166 None
2167 );
2168 assert_eq!(
2169 inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
2170 None
2171 );
2172 assert_eq!(inline_signing_key_blob("ABCD1234"), None);
2173 }
2174
2175 /// D-03/D-12: values that neither start with `key::` nor `ssh-` still
2176 /// take the path branch and keep today's byte-for-byte behavior — the
2177 /// early `.exists()` return, which still fires before anything is
2178 /// spawned. What follows that early return is now the signing probe
2179 /// rather than the deleted `ssh-add` predictor; the guarantee under
2180 /// test is that a missing file is answered without reaching it at all.
2181 /// This is the D-03 falsifier: bare `ecdsa-`/`sk-` forms must NOT be
2182 /// treated as inline.
2183 #[test]
2184 fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
2185 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
2186 let path_values = [
2187 "/nonexistent/path/to/a/signing/key/that/does/not/exist",
2188 "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
2189 "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
2190 ];
2191 for value in path_values {
2192 let repo = init_repo();
2193 let root = repo.path();
2194 git(root, &["config", "gpg.format", "ssh"]);
2195 git(root, &["config", "user.signingkey", value]);
2196
2197 let result = check_signing_viability(root);
2198
2199 assert_eq!(
2200 result,
2201 SigningViability::NotViable {
2202 reason: MISSING_FILE_REASON.to_string(),
2203 },
2204 "value {value:?} did not take the path branch: {result:?}"
2205 );
2206 }
2207 }
2208
2209 /// D-06: every inline-branch failure mode must degrade to `Unknown`,
2210 /// never a NEW hard fail introduced by this phase. That guarantee is
2211 /// what this test preserves; only the reasons it accepts changed.
2212 ///
2213 /// It used to pin a two-reason set built from the predictor's no-agent
2214 /// and agent-empty strings, neither of which the code can produce any
2215 /// more — a test that referenced the deleted mechanism through its
2216 /// output rather than its symbols, so nothing but a run would have
2217 /// caught it. Under D-03 an unparseable inline value classifies as
2218 /// inline and returns the single fixed inline reason without being
2219 /// probed at all.
2220 ///
2221 /// Its agent-independence note survives, now true by construction
2222 /// rather than by argument: these values never reach a probe, so no
2223 /// host's agent state can reach this result.
2224 #[test]
2225 fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
2226 const INLINE_REASON: &str =
2227 "cannot verify signing viability — an inline user.signingkey is not probed";
2228 let unparseable_values = ["key::", "key::this is not a key at all"];
2229 for value in unparseable_values {
2230 let repo = init_repo();
2231 let root = repo.path();
2232 git(root, &["config", "gpg.format", "ssh"]);
2233 git(root, &["config", "user.signingkey", value]);
2234
2235 let result = check_signing_viability(root);
2236
2237 assert_eq!(
2238 result,
2239 SigningViability::Unknown {
2240 reason: INLINE_REASON.into(),
2241 },
2242 "value {value:?} produced an unexpected hard fail: {result:?}"
2243 );
2244 }
2245 }
2246
2247 // -----------------------------------------------------------------
2248 // 35-03: the `ssh-keygen -Y sign` probe
2249 // -----------------------------------------------------------------
2250
2251 /// F-8: the probe workspace name must be unique per CALL, not per
2252 /// process. `cargo test` runs tests as parallel THREADS inside a single
2253 /// process, so a name derived from the process id is shared by every
2254 /// concurrent probe: two probes collide, the loser's non-recursive
2255 /// `create_dir` fails with an already-exists error, and it fails soft to
2256 /// `Unknown` — a flaky test whose failure points at the probe rather
2257 /// than at the harness.
2258 ///
2259 /// The two-thread half is load-bearing. The single-thread half alone
2260 /// passes against a name built from the process id plus a thread id,
2261 /// which is the near-miss fix this test exists to reject.
2262 #[test]
2263 fn probe_workspace_name_is_unique_per_call() {
2264 let first = probe_workspace_name();
2265 let second = probe_workspace_name();
2266 assert_ne!(
2267 first, second,
2268 "two successive calls on one thread produced the same probe workspace name"
2269 );
2270
2271 const PER_THREAD: usize = 64;
2272 let handles: Vec<_> = (0..2)
2273 .map(|_| {
2274 std::thread::spawn(|| {
2275 (0..PER_THREAD)
2276 .map(|_| probe_workspace_name())
2277 .collect::<Vec<_>>()
2278 })
2279 })
2280 .collect();
2281 let mut names: Vec<String> = handles
2282 .into_iter()
2283 .flat_map(|handle| handle.join().expect("probe-name thread panicked"))
2284 .collect();
2285 let total = names.len();
2286 assert_eq!(total, 2 * PER_THREAD, "fixture did not produce every name");
2287 names.sort();
2288 names.dedup();
2289 assert_eq!(
2290 names.len(),
2291 total,
2292 "two concurrently spawned threads produced duplicate probe workspace names"
2293 );
2294 }
2295
2296 /// WR-07 (35-REVIEW): "creates a **private** workspace" must be
2297 /// implemented, not merely claimed. `std::fs::create_dir` applies
2298 /// `0o777 & !umask` — typically 0o755 — inside a shared
2299 /// `std::env::temp_dir()`, leaving the directory world-readable and
2300 /// world-traversable. Nothing secret lands in it, so this was never an
2301 /// exposure of key material; the hazard is a future author extending the
2302 /// probe on the strength of the comment.
2303 ///
2304 /// **The umask is neutralized, and that is the whole measurement.** Asserted
2305 /// naively this test is VACUOUS on any host whose umask is already 0o077 —
2306 /// `0o777 & !0o077` is 0o700, so a plain `std::fs::create_dir` produces the
2307 /// expected mode and the assertion passes against the unfixed code. That was
2308 /// observed here, not reasoned about: the first version of this test passed
2309 /// with the `DirBuilderExt::mode` call removed. Setting the umask to 0 makes
2310 /// a plain creation 0o777, so the two really do differ, and the sibling
2311 /// created that way is the negative control.
2312 ///
2313 /// The window spans two `create_dir` calls with no I/O between them. A
2314 /// concurrent test creating a file inside it would get a laxer mode than
2315 /// usual — every such file is a tempdir artifact in a test process, so there
2316 /// is no consequence beyond the mode bits themselves.
2317 ///
2318 /// The non-recursive refusal is asserted here too: `create_dir_all` would
2319 /// accept a pre-planted directory or symlink silently and redirect where the
2320 /// payload is written (T-35-12).
2321 #[test]
2322 fn the_probe_workspace_is_owner_only_and_refuses_an_existing_path() {
2323 use std::os::unix::fs::PermissionsExt;
2324
2325 let dir = tempfile::tempdir().unwrap();
2326 let workspace = dir.path().join("probe");
2327 let plain = dir.path().join("plain");
2328
2329 // SAFETY: `umask` is a plain syscall with no preconditions. Restored
2330 // immediately below, before any assertion can unwind past it.
2331 let previous_umask = unsafe { libc::umask(0) };
2332 let created = create_probe_workspace(&workspace);
2333 let plain_created = std::fs::create_dir(&plain).is_ok();
2334 // SAFETY: restoring the value the call above returned.
2335 unsafe {
2336 libc::umask(previous_umask);
2337 }
2338
2339 assert!(created, "the fixture needs the creation to succeed");
2340 assert!(plain_created, "the fixture needs the control to be created");
2341
2342 let control = std::fs::metadata(&plain).unwrap().permissions().mode() & 0o777;
2343 assert_eq!(
2344 control, 0o777,
2345 "NEGATIVE CONTROL: with the umask neutralized a default creation must be wide \
2346 open. If it is not, the umask window did not take and the assertion below \
2347 cannot distinguish the fix from the default"
2348 );
2349
2350 let mode = std::fs::metadata(&workspace).unwrap().permissions().mode() & 0o777;
2351 assert_eq!(
2352 mode, 0o700,
2353 "the workspace must be owner-only by request, not by whatever the umask happened \
2354 to strip"
2355 );
2356
2357 // The path now exists, so the same call must refuse it.
2358 assert!(
2359 !create_probe_workspace(&workspace),
2360 "a pre-planted directory or symlink must not be adopted — that is how a payload \
2361 gets written somewhere the probe did not choose"
2362 );
2363 }
2364
2365 /// WR-07's second half: "removes the workspace on **every** exit path" was
2366 /// a plain statement after the call, which an unwind skips. Repeated over
2367 /// many `release --check` runs on a long-lived host that is unbounded
2368 /// accumulation of `devflow-sign-probe-*` directories in `/tmp`.
2369 ///
2370 /// Driven through a real `catch_unwind` rather than by calling `drop`:
2371 /// dropping the guard by hand proves only that `Drop` removes a directory,
2372 /// which was never in question. The claim is that the removal survives a
2373 /// panic, and only an unwind establishes that.
2374 ///
2375 /// The control is the directory's existence before the panic — without it a
2376 /// test that never created anything would pass.
2377 ///
2378 /// # What this does NOT establish
2379 ///
2380 /// Its subject is [`ProbeWorkspace`], not [`run_ssh_sign_probe`]. Measured,
2381 /// not assumed: reverting `run_ssh_sign_probe` to the trailing
2382 /// `remove_dir_all` statement leaves this test PASSING. Nothing here can
2383 /// panic on demand inside `sign_probe_within`, so the link from the
2384 /// production function to the guard rests on there being exactly one
2385 /// construction site, checked by reading. A future refactor that stops
2386 /// binding the guard would not be caught here.
2387 #[test]
2388 fn the_probe_workspace_guard_removes_its_directory_on_unwind() {
2389 let dir = tempfile::tempdir().unwrap();
2390 let workspace = dir.path().join("probe");
2391 assert!(create_probe_workspace(&workspace));
2392 assert!(
2393 workspace.exists(),
2394 "premise: the directory must exist before the panic, or its later absence \
2395 establishes nothing"
2396 );
2397
2398 let panicked = std::panic::catch_unwind({
2399 let workspace = workspace.clone();
2400 move || {
2401 let _cleanup = ProbeWorkspace(workspace);
2402 panic!("the probe panicked mid-flight");
2403 }
2404 })
2405 .is_err();
2406
2407 assert!(panicked, "the fixture must actually unwind");
2408 assert!(
2409 !workspace.exists(),
2410 "a panic inside the probe must not leak its workspace into the shared temp dir"
2411 );
2412 }
2413
2414 /// WR-01 (35-REVIEW): a probe timeout is a measurement failure, so it
2415 /// must land on `Unknown`/`warn` beside the other two non-verdicts — not
2416 /// on a hard `NotViable`, which asserts something about the key and
2417 /// attaches `release --check`'s "resolve before attempting the signed
2418 /// release tag" hint to a key that may sign perfectly well.
2419 ///
2420 /// `Rejected` is the NC-4 negative control and is checked in the same
2421 /// function on purpose here: it is the one outcome that genuinely IS
2422 /// evidence about the key, so a mapping that returned `Unknown`
2423 /// unconditionally — the obvious over-correction — fails on it. If both
2424 /// halves agreed, this test would be measuring nothing.
2425 ///
2426 /// Asserted on the classification rather than by wedging a real
2427 /// `ssh-keygen` for ten seconds: the defect was in the mapping, and a
2428 /// wall-clock probe would make this a slow test of the timeout mechanism
2429 /// (which `SSH_SIGN_PROBE_TIMEOUT`'s own tests already cover) instead of
2430 /// a fast test of the verdict.
2431 #[test]
2432 fn a_probe_timeout_is_unknown_while_a_rejection_stays_not_viable() {
2433 // Never read: no arm below reaches `public_key_fingerprint`.
2434 let unused_key = Path::new("/nonexistent/devflow-wr01");
2435
2436 let timed_out = sign_probe_verdict(SignProbeOutcome::TimedOut, unused_key);
2437 match &timed_out {
2438 SigningViability::Unknown { reason } => assert!(
2439 reason.starts_with("cannot verify signing viability — "),
2440 "a non-verdict must carry the file's fail-soft prefix, got: {reason:?}"
2441 ),
2442 other => panic!(
2443 "a timeout establishes nothing about the key and must not be a hard \
2444 verdict, got: {other:?}"
2445 ),
2446 }
2447
2448 let rejected = sign_probe_verdict(SignProbeOutcome::Rejected, unused_key);
2449 assert!(
2450 matches!(rejected, SigningViability::NotViable { .. }),
2451 "NEGATIVE CONTROL: a key that ran the probe and could not sign IS evidence \
2452 about the key and must stay a hard verdict, got: {rejected:?}"
2453 );
2454
2455 // The other two fail-soft classes, pinned in the same place so the
2456 // three "could not establish anything" outcomes cannot drift apart
2457 // again.
2458 for outcome in [SignProbeOutcome::ToolMissing, SignProbeOutcome::NotRun] {
2459 assert!(
2460 matches!(
2461 sign_probe_verdict(outcome, unused_key),
2462 SigningViability::Unknown { .. }
2463 ),
2464 "every measurement failure maps to Unknown"
2465 );
2466 }
2467 }
2468
2469 /// Generate a real ed25519 keypair at `stem`, with `passphrase` (empty
2470 /// for an unencrypted key). Returns the public half's path.
2471 fn generate_keypair(stem: &Path, passphrase: &str) -> PathBuf {
2472 let keygen = Command::new("ssh-keygen")
2473 .args([
2474 "-t",
2475 "ed25519",
2476 "-f",
2477 stem.to_str().unwrap(),
2478 "-N",
2479 passphrase,
2480 "-q",
2481 ])
2482 .output()
2483 .expect("spawn ssh-keygen");
2484 assert!(
2485 keygen.status.success(),
2486 "ssh-keygen fixture setup failed: {}",
2487 String::from_utf8_lossy(&keygen.stderr)
2488 );
2489 let pub_path = stem.with_extension("pub");
2490 assert!(pub_path.exists(), "ssh-keygen wrote no public key");
2491 pub_path
2492 }
2493
2494 /// Point a repo's `user.signingkey` at `key` under `gpg.format=ssh`.
2495 fn configure_ssh_signing(root: &Path, key: &Path) {
2496 git(root, &["config", "gpg.format", "ssh"]);
2497 git(root, &["config", "user.signingkey", key.to_str().unwrap()]);
2498 }
2499
2500 /// D-08's redaction contract, asserted on the rendered result: neither
2501 /// the reason nor the fingerprint may carry the configured key path, any
2502 /// private key material, or any fragment of `ssh-keygen`'s own stderr
2503 /// (which embeds the path verbatim — see the probe's own comment).
2504 fn assert_no_leak(result: &SigningViability, secret_dir: &Path) {
2505 let rendered = format!("{result:?}");
2506 assert!(
2507 !rendered.contains(secret_dir.to_str().unwrap()),
2508 "signing viability leaked a filesystem path: {rendered}"
2509 );
2510 for fragment in [
2511 "PRIVATE KEY",
2512 "No private key found",
2513 "Couldn't load public key",
2514 "Enter passphrase",
2515 "incorrect passphrase",
2516 ] {
2517 assert!(
2518 !rendered.contains(fragment),
2519 "signing viability leaked key material or ssh-keygen stderr ({fragment:?}): \
2520 {rendered}"
2521 );
2522 }
2523 }
2524
2525 /// The headline case, and the exact live false negative 999.86 was filed
2526 /// for twice: a configured signing key whose unencrypted private sibling
2527 /// is on disk signs fine with NO agent involvement at all. The predictor
2528 /// this replaced asked `ssh-add -l` whether the agent held the key and
2529 /// reported `NotViable` when it did not — agent membership is simply not
2530 /// a necessary condition for `git tag -s` to succeed.
2531 ///
2532 /// This test therefore reads, sets and depends on NO agent state. The
2533 /// fixture key is generated fresh into a temporary directory, so no
2534 /// agent on any host can be holding it; a `Viable` verdict here is only
2535 /// reachable through the on-disk private key.
2536 #[test]
2537 fn ssh_signing_probe_reports_viable_with_on_disk_private_key() {
2538 let repo = init_repo();
2539 let root = repo.path();
2540 let keys = tempfile::tempdir().unwrap();
2541 let pub_key = generate_keypair(&keys.path().join("probe-key"), "");
2542 configure_ssh_signing(root, &pub_key);
2543
2544 let result = check_signing_viability(root);
2545
2546 match &result {
2547 SigningViability::Viable { fingerprint } => {
2548 let fingerprint = fingerprint
2549 .as_deref()
2550 .expect("Viable must carry the public key fingerprint");
2551 assert!(
2552 fingerprint.starts_with("SHA256:"),
2553 "unexpected fingerprint shape: {fingerprint}"
2554 );
2555 }
2556 other => panic!("expected Viable for an on-disk private key, got: {other:?}"),
2557 }
2558 assert_no_leak(&result, keys.path());
2559 }
2560
2561 /// NC-9, the negative control for the test above. Same fixture with the
2562 /// private half deleted, leaving only the `.pub` file: the verdict must
2563 /// FLIP to `NotViable`. Without this the positive assertion is vacuously
2564 /// true — a probe that returned `Viable` unconditionally would pass the
2565 /// positive case and fail here.
2566 #[test]
2567 fn ssh_signing_probe_reports_not_viable_without_a_private_key() {
2568 let repo = init_repo();
2569 let root = repo.path();
2570 let keys = tempfile::tempdir().unwrap();
2571 let stem = keys.path().join("probe-key");
2572 let pub_key = generate_keypair(&stem, "");
2573 std::fs::remove_file(&stem).expect("remove the private half");
2574 assert!(!stem.exists(), "fixture still has a private key");
2575 configure_ssh_signing(root, &pub_key);
2576
2577 let result = check_signing_viability(root);
2578
2579 assert_eq!(
2580 result,
2581 SigningViability::NotViable {
2582 reason: "the configured signing key could not sign a test payload".into(),
2583 },
2584 "expected the verdict to flip without a private key, got: {result:?}"
2585 );
2586 assert_no_leak(&result, keys.path());
2587 }
2588
2589 /// Build one raw `ssh-keygen -Y sign` invocation for NC-10.
2590 ///
2591 /// Deliberately drives the raw command rather than the probe: the
2592 /// observation must be of `SSH_ASKPASS_REQUIRE`'s effect, and a run
2593 /// through the probe would have its duration pinned by
2594 /// [`SSH_SIGN_PROBE_TIMEOUT`] instead — measuring the constant, not the
2595 /// variable.
2596 fn askpass_arm(dir: &Path, askpass_require: Option<&str>) -> std::process::Child {
2597 use std::os::unix::process::CommandExt;
2598
2599 let payload = dir.join(probe_workspace_name());
2600 std::fs::write(&payload, b"nc-10 payload\n").expect("write nc-10 payload");
2601
2602 let mut command = Command::new("ssh-keygen");
2603 command
2604 .args([
2605 "-Y",
2606 "sign",
2607 "-n",
2608 SSH_SIGN_NAMESPACE,
2609 "-f",
2610 dir.join("encrypted-key.pub").to_str().unwrap(),
2611 payload.to_str().unwrap(),
2612 ])
2613 .env("SSH_ASKPASS", dir.join("askpass.sh"))
2614 // `read_passphrase` only consults SSH_ASKPASS when DISPLAY or
2615 // SSH_ASKPASS is set; both arms get the same askpass route so
2616 // the ONLY difference between them is the variable under test.
2617 .env("DISPLAY", ":0")
2618 // Agent state is deliberately neither read nor cleared here.
2619 // The fixture key is generated microseconds earlier into a fresh
2620 // temporary directory, so no agent on any host can hold it, and
2621 // a test that reaches for agent state near a signing assertion
2622 // has reproduced the very premise that produced 999.86.
2623 .stdin(Stdio::null())
2624 .stdout(Stdio::null())
2625 .stderr(Stdio::null());
2626 match askpass_require {
2627 Some(value) => command.env("SSH_ASKPASS_REQUIRE", value),
2628 None => command.env_remove("SSH_ASKPASS_REQUIRE"),
2629 };
2630
2631 // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is
2632 // the only requirement `pre_exec` imposes.
2633 //
2634 // This is load-bearing, not hygiene. With a controlling terminal
2635 // available, `ssh-keygen` prompts for the passphrase on `/dev/tty`
2636 // regardless of SSH_ASKPASS_REQUIRE, so BOTH arms would block and
2637 // the control would agree with its positive case for a reason that
2638 // has nothing to do with the variable. Dropping the terminal forces
2639 // the askpass route, which is the route the variable governs. It
2640 // also stops a killed child from leaving an operator's terminal
2641 // with echo disabled.
2642 unsafe {
2643 command.pre_exec(|| {
2644 if libc::setsid() == -1 {
2645 return Err(std::io::Error::last_os_error());
2646 }
2647 Ok(())
2648 });
2649 }
2650 command.spawn().expect("spawn ssh-keygen for NC-10")
2651 }
2652
2653 /// Write the NC-10 fixture: an encrypted ed25519 key and an askpass
2654 /// helper that takes far longer than any observation window here.
2655 fn encrypted_key_fixture() -> tempfile::TempDir {
2656 let dir = tempfile::tempdir().unwrap();
2657 generate_keypair(&dir.path().join("encrypted-key"), "devflow-nc10-passphrase");
2658 let askpass = dir.path().join("askpass.sh");
2659 std::fs::write(
2660 &askpass,
2661 "#!/bin/sh\nsleep 5\necho devflow-nc10-passphrase\n",
2662 )
2663 .unwrap();
2664 let mut perms = std::fs::metadata(&askpass).unwrap().permissions();
2665 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2666 std::fs::set_permissions(&askpass, perms).unwrap();
2667 dir
2668 }
2669
2670 /// Poll `child` for up to `window`, returning how long it took to exit
2671 /// or `None` if it was still running when the window closed.
2672 fn wait_bounded(child: &mut std::process::Child, window: Duration) -> Option<Duration> {
2673 let started = Instant::now();
2674 let deadline = started + window;
2675 loop {
2676 match child.try_wait().expect("poll nc-10 child") {
2677 Some(_) => return Some(started.elapsed()),
2678 None => {
2679 if Instant::now() >= deadline {
2680 return None;
2681 }
2682 std::thread::sleep(Duration::from_millis(5));
2683 }
2684 }
2685 }
2686 }
2687
2688 /// NC-10, positive arm: with `SSH_ASKPASS_REQUIRE=never` an encrypted
2689 /// key does NOT park on the askpass helper — it gives up promptly.
2690 #[test]
2691 fn ssh_signing_probe_does_not_block_on_an_encrypted_key() {
2692 let dir = encrypted_key_fixture();
2693 let mut child = askpass_arm(dir.path(), Some("never"));
2694 let elapsed = wait_bounded(&mut child, SSH_SIGN_PROBE_TIMEOUT / 2);
2695 if elapsed.is_none() {
2696 let _ = child.kill();
2697 let _ = child.wait();
2698 }
2699 let elapsed = elapsed.expect(
2700 "SSH_ASKPASS_REQUIRE=never did not stop ssh-keygen blocking on the askpass helper",
2701 );
2702 eprintln!("NC-10 non-blocking arm exited in {elapsed:?}");
2703 assert!(
2704 elapsed < Duration::from_secs(2),
2705 "the non-blocking arm took {elapsed:?}, which is too slow to calibrate a control"
2706 );
2707 }
2708
2709 /// NC-10's control (D-01). The env var, not the fixture and not the
2710 /// timeout, is what prevents the hang — so the SAME fixture is run with
2711 /// the variable OMITTED and must still be alive when the window closes.
2712 ///
2713 /// **The window is calibrated, not assumed (F-9).** The non-blocking arm
2714 /// runs first and its wall-clock exit is measured; the window is derived
2715 /// from that measurement at a stated multiple. An uncalibrated window
2716 /// shorter than the time `ssh-keygen` ordinarily takes to give up would
2717 /// report "blocked" for a reason that has nothing to do with the
2718 /// variable, and the control would pass while measuring the wrong thing.
2719 /// The window is also held well under [`SSH_SIGN_PROBE_TIMEOUT`], so the
2720 /// measurement is of the variable's effect rather than of the ceiling.
2721 ///
2722 /// A control that agrees with its positive case is a broken measurement,
2723 /// not evidence: if the blocking arm does NOT block, this test fails
2724 /// loudly rather than passing.
2725 #[test]
2726 fn encrypted_key_blocks_without_the_askpass_require_env_var() {
2727 const CALIBRATION_MULTIPLE: u32 = 8;
2728 const MIN_WINDOW: Duration = Duration::from_millis(1000);
2729
2730 let dir = encrypted_key_fixture();
2731
2732 // Arm 1 — measure. Same fixture, variable set.
2733 let mut baseline_child = askpass_arm(dir.path(), Some("never"));
2734 let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
2735 if baseline.is_none() {
2736 let _ = baseline_child.kill();
2737 let _ = baseline_child.wait();
2738 }
2739 let baseline = baseline.expect(
2740 "control uncalibrated: the non-blocking arm never exited, so there is no baseline \
2741 to derive an observation window from",
2742 );
2743
2744 // Derive the window from the measurement rather than assuming one.
2745 let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
2746 assert!(
2747 window >= baseline * 4,
2748 "control uncalibrated: observation window {window:?} is not at least four times \
2749 the measured non-blocking exit of {baseline:?}"
2750 );
2751 assert!(
2752 window < SSH_SIGN_PROBE_TIMEOUT / 2,
2753 "control uncalibrated: observation window {window:?} is not comfortably under the \
2754 probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling \
2755 rather than SSH_ASKPASS_REQUIRE"
2756 );
2757
2758 // Arm 2 — the control. Same fixture, variable omitted.
2759 let mut blocking_child = askpass_arm(dir.path(), None);
2760 let blocked = wait_bounded(&mut blocking_child, window);
2761 let _ = blocking_child.kill();
2762 let _ = blocking_child.wait();
2763
2764 eprintln!(
2765 "NC-10 calibration: non-blocking exit {baseline:?}, observation window {window:?} \
2766 ({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}), blocking arm {blocked:?}"
2767 );
2768 assert!(
2769 blocked.is_none(),
2770 "NC-10 control FAILED: with SSH_ASKPASS_REQUIRE omitted the child still exited in \
2771 {blocked:?}, inside the {window:?} window. A control that agrees with its positive \
2772 case is a broken measurement, not evidence — nothing here supports the conclusion \
2773 that the environment variable is what prevents the hang"
2774 );
2775 }
2776
2777 // ------------------------------------------------------------------
2778 // D8 / HARDEN-05 — the PRODUCTION probe drops its controlling terminal.
2779 //
2780 // The two NC-10 arms above install their OWN `setsid`, so both would pass
2781 // byte-unchanged if the production `pre_exec` were deleted: 35-03's
2782 // SUMMARY records exactly that, as `human_judgment: true`. The test below
2783 // is the missing guard. It runs the production probe from a child that has
2784 // ACQUIRED a pty as its controlling terminal, which is the only condition
2785 // under which the production `setsid` does anything observable at all.
2786 // ------------------------------------------------------------------
2787
2788 /// Carries the fixture key into the re-executed child.
2789 const TTY_PROBE_KEY_ENV: &str = "DEVFLOW_TTY_PROBE_KEY";
2790
2791 /// The child entrypoint's name, as libtest's `--exact` filter sees it.
2792 const TTY_PROBE_CHILD: &str = "git::tests::ssh_sign_probe_tty_child_entrypoint";
2793
2794 // Exit codes for that child. **None of them is 0, deliberately.** `cargo
2795 // test --exact <name>` exits 0 when the name matches nothing (CLAUDE.md;
2796 // this repo has already paid for it), so a renamed entrypoint would make
2797 // the measuring arm below "succeed" in milliseconds while running no probe
2798 // whatsoever. A 0 exit therefore means "the child never ran" and is
2799 // asserted against explicitly.
2800 const EXIT_PROBE_REJECTED: i32 = 42;
2801 const EXIT_PROBE_TIMED_OUT: i32 = 43;
2802 const EXIT_PROBE_OTHER: i32 = 44;
2803 const EXIT_NO_CONTROLLING_TTY: i32 = 97;
2804
2805 /// A pty pair whose fds are closed on every exit path, including unwind.
2806 ///
2807 /// Closing the master hangs up the line, which is also the backstop that
2808 /// stops anything still sitting on a passphrase prompt from outliving this
2809 /// test: the session leader's death sends `SIGHUP` to the foreground
2810 /// process group.
2811 struct Pty {
2812 master: libc::c_int,
2813 slave: libc::c_int,
2814 }
2815
2816 impl Pty {
2817 /// Allocate a pty pair. `O_NOCTTY` throughout — *this* process must
2818 /// not acquire the terminal; only the child spawned by
2819 /// [`spawn_owning_controlling_tty`] may, and only via an explicit
2820 /// `TIOCSCTTY`.
2821 fn open() -> Pty {
2822 // SAFETY: each call below is a bare libc entry point with
2823 // in-bounds arguments (`name` is sized and its length passed), and
2824 // the `Pty` is constructed as soon as the first fd exists, so its
2825 // `Drop` owns every descriptor from that point on — including
2826 // across the assertion unwinds between here and the return.
2827 unsafe {
2828 let master = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY);
2829 assert!(
2830 master >= 0,
2831 "posix_openpt failed: {}",
2832 std::io::Error::last_os_error()
2833 );
2834 let mut pty = Pty { master, slave: -1 };
2835 assert!(
2836 libc::grantpt(master) == 0,
2837 "grantpt failed: {}",
2838 std::io::Error::last_os_error()
2839 );
2840 assert!(
2841 libc::unlockpt(master) == 0,
2842 "unlockpt failed: {}",
2843 std::io::Error::last_os_error()
2844 );
2845 let mut name = [0 as libc::c_char; 128];
2846 assert!(
2847 libc::ptsname_r(master, name.as_mut_ptr(), name.len()) == 0,
2848 "ptsname_r failed: {}",
2849 std::io::Error::last_os_error()
2850 );
2851 let slave = libc::open(name.as_ptr(), libc::O_RDWR | libc::O_NOCTTY);
2852 assert!(
2853 slave >= 0,
2854 "opening the pty slave failed: {}",
2855 std::io::Error::last_os_error()
2856 );
2857 pty.slave = slave;
2858 pty
2859 }
2860 }
2861 }
2862
2863 impl Drop for Pty {
2864 fn drop(&mut self) {
2865 // SAFETY: both descriptors were opened by `Pty::open` and are
2866 // closed exactly once, here.
2867 unsafe {
2868 if self.slave >= 0 {
2869 libc::close(self.slave);
2870 }
2871 libc::close(self.master);
2872 }
2873 }
2874 }
2875
2876 /// Spawn `command` as the leader of a NEW session that has acquired
2877 /// `pty`'s slave as its **controlling terminal**.
2878 ///
2879 /// Both syscalls are checked here, unlike the production probe's
2880 /// deliberate ignore. A silently failed `TIOCSCTTY` would leave the child
2881 /// with no controlling terminal — the single condition under which every
2882 /// assertion in this test passes for the wrong reason — so a failure is
2883 /// returned as an error and surfaces as a loud `spawn` failure instead.
2884 fn spawn_owning_controlling_tty(command: &mut Command, pty: &Pty) -> std::process::Child {
2885 use std::os::unix::process::CommandExt;
2886
2887 let slave = pty.slave;
2888 // SAFETY: `setsid` and `ioctl` are bare syscalls and async-signal-safe,
2889 // which is the only requirement `pre_exec` imposes. `slave` is owned by
2890 // the `Pty` the caller holds for the child's whole lifetime, and it is
2891 // inherited across the fork because it was opened without `CLOEXEC`.
2892 unsafe {
2893 command.pre_exec(move || {
2894 if libc::setsid() == -1 {
2895 return Err(std::io::Error::last_os_error());
2896 }
2897 if libc::ioctl(slave, libc::TIOCSCTTY, 0) == -1 {
2898 return Err(std::io::Error::last_os_error());
2899 }
2900 Ok(())
2901 });
2902 }
2903 command
2904 .spawn()
2905 .expect("spawn a child owning the pty as its controlling terminal")
2906 }
2907
2908 /// Spawn a child GUARANTEED to have no controlling terminal, by putting it
2909 /// in a fresh session and giving it no pty to acquire.
2910 ///
2911 /// Arm 0 must not merely *assume* the ambient environment lacks a terminal
2912 /// — that assumption is environment-dependent and it is false under the
2913 /// pre-push gate, which runs `docker run --rm -t` (`check-in-container.sh`)
2914 /// and therefore hands the test binary a pty as its controlling terminal.
2915 /// Inheriting it made the control arm block on `/dev/tty`, which the
2916 /// calibration guard correctly reported as `control uncalibrated` rather
2917 /// than mis-attributing it to `setsid` — a hard red in the container while
2918 /// this same test passed on a terminal-less host.
2919 ///
2920 /// Detaching explicitly makes the arm mean the same thing in both places.
2921 fn spawn_detached_from_terminal(command: &mut Command) -> std::process::Child {
2922 use std::os::unix::process::CommandExt;
2923
2924 // SAFETY: `setsid` is a bare syscall and async-signal-safe, which is
2925 // the only requirement `pre_exec` imposes. A freshly forked child is
2926 // never already a process-group leader, so the call cannot fail for
2927 // the one reason `setsid` fails; it is still checked rather than
2928 // ignored, because a silent failure here would leave the child holding
2929 // an inherited terminal and turn this control back into the very
2930 // environment-dependent arm it exists to replace.
2931 unsafe {
2932 command.pre_exec(|| {
2933 if libc::setsid() == -1 {
2934 return Err(std::io::Error::last_os_error());
2935 }
2936 Ok(())
2937 });
2938 }
2939 command
2940 .spawn()
2941 .expect("spawn a child detached from any controlling terminal")
2942 }
2943
2944 /// One raw `ssh-keygen -Y sign` against an encrypted key, mirroring the
2945 /// production probe's environment and stdio EXACTLY — and deliberately
2946 /// **not** calling `setsid` itself. Each arm decides its own session: arm 0
2947 /// via [`spawn_detached_from_terminal`] (fresh session, no pty), arm 1 via
2948 /// [`spawn_owning_controlling_tty`] (fresh session, then `TIOCSCTTY`). The
2949 /// only thing that differs between the two arms built from it is therefore
2950 /// whether the child holds a controlling terminal — and now that holds by
2951 /// construction rather than by inheritance from whatever spawned the tests.
2952 fn tty_control_arm(key_pub: &Path, payload: &Path) -> Command {
2953 let mut command = Command::new("ssh-keygen");
2954 command
2955 .args([
2956 "-Y",
2957 "sign",
2958 "-n",
2959 SSH_SIGN_NAMESPACE,
2960 "-f",
2961 key_pub.to_str().unwrap(),
2962 payload.to_str().unwrap(),
2963 ])
2964 .env("SSH_ASKPASS_REQUIRE", "never")
2965 .stdin(Stdio::null())
2966 .stdout(Stdio::null())
2967 .stderr(Stdio::null());
2968 command
2969 }
2970
2971 /// Re-entry point for the D8 test below: runs the **production** probe
2972 /// inside whatever session its caller placed this process in, and reports
2973 /// the verdict as a process exit code.
2974 ///
2975 /// A no-op unless [`TTY_PROBE_KEY_ENV`] is set, so an ordinary `cargo test`
2976 /// run spawns nothing and costs nothing here.
2977 ///
2978 /// The `/dev/tty` open is a **premise check, not hygiene**: without it a
2979 /// `TIOCSCTTY` that silently failed would leave this child with no
2980 /// terminal, the probe would return promptly for a reason having nothing to
2981 /// do with `setsid`, and the test would pass while measuring nothing.
2982 #[test]
2983 fn ssh_sign_probe_tty_child_entrypoint() {
2984 let Ok(key) = std::env::var(TTY_PROBE_KEY_ENV) else {
2985 return;
2986 };
2987
2988 let tty_path = std::ffi::CString::new("/dev/tty").unwrap();
2989 // SAFETY: `tty_path` is a valid NUL-terminated C string that outlives
2990 // the call, and the descriptor is closed on the one path that opens it.
2991 let tty = unsafe { libc::open(tty_path.as_ptr(), libc::O_RDWR) };
2992 if tty < 0 {
2993 std::process::exit(EXIT_NO_CONTROLLING_TTY);
2994 }
2995 // SAFETY: `tty` was just opened by this thread and is closed once.
2996 unsafe { libc::close(tty) };
2997
2998 std::process::exit(match run_ssh_sign_probe(Path::new(&key)) {
2999 SignProbeOutcome::Rejected => EXIT_PROBE_REJECTED,
3000 SignProbeOutcome::TimedOut => EXIT_PROBE_TIMED_OUT,
3001 _ => EXIT_PROBE_OTHER,
3002 });
3003 }
3004
3005 /// **D8 (HARDEN-05): the production signing probe is not captured by a
3006 /// controlling terminal's `/dev/tty` passphrase prompt.**
3007 ///
3008 /// `SSH_ASKPASS_REQUIRE=never` is not sufficient on its own — OpenSSH only
3009 /// consults it after `open("/dev/tty")` has already failed. The production
3010 /// `pre_exec`/`setsid` is what makes that open fail. Delete it and this
3011 /// test fails: the probe blocks on the terminal until its own 10 s ceiling.
3012 ///
3013 /// Three arms, and the first two are a **matched pair that must disagree**:
3014 ///
3015 /// | arm | terminal | `setsid` | required result |
3016 /// |---|---|---|---|
3017 /// | 0 — baseline/control | none | none | exits promptly |
3018 /// | 1 — premise | pty, acquired | none | still blocked when the window closes |
3019 /// | 2 — measurement | pty, acquired | production's | exits promptly, with a real verdict |
3020 ///
3021 /// If arms 0 and 1 agreed, the harness would have established nothing —
3022 /// either the terminal never took effect, or this OpenSSH build does not
3023 /// use it — and arm 2 would be fast for a reason unrelated to `setsid`.
3024 /// Arm 1 therefore runs BEFORE the measurement and fails as a PREMISE
3025 /// failure, not as a regression.
3026 ///
3027 /// The observation window is derived from arm 0's measured exit, at a
3028 /// stated multiple, following NC-10's calibration shape; every wait is
3029 /// bounded and every child is killed and reaped on every path.
3030 #[test]
3031 fn the_signing_probe_is_not_captured_by_a_controlling_terminal() {
3032 const CALIBRATION_MULTIPLE: u32 = 8;
3033 const MIN_WINDOW: Duration = Duration::from_millis(1000);
3034 /// Bound for the production arm: far above a real verdict (tens of
3035 /// milliseconds plus one process spawn) and far below the probe's own
3036 /// [`SSH_SIGN_PROBE_TIMEOUT`], so exceeding it means "ran to the
3037 /// ceiling on the terminal", not "this host is slow".
3038 const PROBE_ARM_CAP: Duration = Duration::from_millis(3000);
3039
3040 let dir = tempfile::tempdir().unwrap();
3041 let key_pub = generate_keypair(&dir.path().join("tty-key"), "devflow-d8-passphrase");
3042 let payload = dir.path().join("payload");
3043 std::fs::write(&payload, b"devflow d8 tty payload\n").unwrap();
3044
3045 // --- Arm 0: the paired control. Same command, same environment, NO
3046 // controlling terminal. `readpassphrase` falls back to a nulled stdin
3047 // and gives up at once. The child is detached into its own session
3048 // explicitly rather than trusting the ambient environment to lack a
3049 // terminal — see `spawn_detached_from_terminal`.
3050 let mut baseline_child =
3051 spawn_detached_from_terminal(&mut tty_control_arm(&key_pub, &payload));
3052 let baseline = wait_bounded(&mut baseline_child, SSH_SIGN_PROBE_TIMEOUT / 2);
3053 if baseline.is_none() {
3054 let _ = baseline_child.kill();
3055 let _ = baseline_child.wait();
3056 }
3057 let baseline = baseline.expect(
3058 "control uncalibrated: ssh-keygen blocked with NO controlling terminal, so nothing \
3059 measured below can be attributed to the terminal",
3060 );
3061
3062 let window = std::cmp::max(baseline * CALIBRATION_MULTIPLE, MIN_WINDOW);
3063 assert!(
3064 window >= baseline * 4,
3065 "control uncalibrated: observation window {window:?} is not at least four times the \
3066 measured no-terminal exit of {baseline:?}"
3067 );
3068 assert!(
3069 window < SSH_SIGN_PROBE_TIMEOUT / 2,
3070 "control uncalibrated: observation window {window:?} is not comfortably under the \
3071 probe's own {SSH_SIGN_PROBE_TIMEOUT:?} ceiling, so it would measure the ceiling"
3072 );
3073
3074 // --- Arm 1: the premise, and the other half of the pair. Same command
3075 // again, WITH the pty acquired as a controlling terminal and no
3076 // `setsid`: it must still be blocked when the window closes.
3077 let blocked = {
3078 let pty = Pty::open();
3079 let mut child =
3080 spawn_owning_controlling_tty(&mut tty_control_arm(&key_pub, &payload), &pty);
3081 let blocked = wait_bounded(&mut child, window);
3082 let _ = child.kill();
3083 let _ = child.wait();
3084 blocked
3085 };
3086 assert!(
3087 blocked.is_none(),
3088 "PREMISE FAILED: with a controlling terminal and no setsid, ssh-keygen exited in \
3089 {blocked:?} — the same result as the no-terminal control ({baseline:?}). Either the \
3090 pty was never acquired or this build does not consult /dev/tty, and either way the \
3091 arm below would be fast for a reason unrelated to the production setsid. A control \
3092 that agrees with its positive case is a broken measurement, not evidence"
3093 );
3094
3095 // --- Arm 2: the measurement. The PRODUCTION probe, same fixture, same
3096 // kind of controlling terminal — re-executed as a child because only a
3097 // separate process can be made a session leader.
3098 let (elapsed, status) = {
3099 let pty = Pty::open();
3100 let mut command = Command::new(
3101 std::env::current_exe().expect("locate this test binary for re-execution"),
3102 );
3103 command
3104 .args([TTY_PROBE_CHILD, "--exact", "--test-threads=1"])
3105 .env(TTY_PROBE_KEY_ENV, &key_pub)
3106 .stdin(Stdio::null())
3107 .stdout(Stdio::null())
3108 .stderr(Stdio::null());
3109 let mut child = spawn_owning_controlling_tty(&mut command, &pty);
3110 let elapsed = wait_bounded(&mut child, PROBE_ARM_CAP);
3111 if elapsed.is_none() {
3112 let _ = child.kill();
3113 let _ = child.wait();
3114 }
3115 let status = elapsed.map(|_| child.wait().expect("reap the production probe arm"));
3116 (elapsed, status)
3117 };
3118 let code = status.and_then(|status| status.code());
3119 eprintln!(
3120 "D8: no-terminal baseline {baseline:?}; window {window:?} \
3121 ({CALIBRATION_MULTIPLE}x, floored at {MIN_WINDOW:?}); with-terminal control \
3122 {blocked:?}; production probe {elapsed:?} exiting {code:?}"
3123 );
3124
3125 let elapsed = elapsed.unwrap_or_else(|| {
3126 panic!(
3127 "REGRESSION: the production signing probe did not return within {PROBE_ARM_CAP:?} \
3128 while its caller held a controlling terminal. That is the pre-setsid behaviour — \
3129 it is parked on /dev/tty waiting for a passphrase nobody can type, and will stay \
3130 there until SSH_SIGN_PROBE_TIMEOUT ({SSH_SIGN_PROBE_TIMEOUT:?}) expires. The \
3131 no-terminal control exited in {baseline:?}, so the fixture and the environment \
3132 are not what changed"
3133 )
3134 });
3135 assert_ne!(
3136 code,
3137 Some(EXIT_NO_CONTROLLING_TTY),
3138 "PREMISE FAILED: the re-executed child could not open /dev/tty, so it never held a \
3139 controlling terminal and its {elapsed:?} says nothing about setsid"
3140 );
3141 assert_ne!(
3142 code,
3143 Some(0),
3144 "the child exited 0, which no path in ssh_sign_probe_tty_child_entrypoint does: \
3145 `--exact {TTY_PROBE_CHILD}` matched no test, so no probe ran at all"
3146 );
3147 assert_eq!(
3148 code,
3149 Some(EXIT_PROBE_REJECTED),
3150 "the probe returned in {elapsed:?} but with the wrong verdict: {} means it hit its \
3151 own ceiling and {} means it never reached one",
3152 EXIT_PROBE_TIMED_OUT,
3153 EXIT_PROBE_OTHER
3154 );
3155 }
3156
3157 /// D-03/A-17: an inline `user.signingkey` returns `Unknown` with the
3158 /// fixed inline reason and is never probed.
3159 ///
3160 /// Run with a NORMAL `PATH`, so `ssh-keygen` is present throughout. That
3161 /// is what makes this a proof of "never probed" rather than of "failed
3162 /// to probe" — the surface test that removes the tooling cannot tell the
3163 /// two apart.
3164 #[test]
3165 fn inline_signing_key_returns_unknown_without_probing() {
3166 const INLINE_REASON: &str =
3167 "cannot verify signing viability — an inline user.signingkey is not probed";
3168 let keys = tempfile::tempdir().unwrap();
3169 let pub_key = generate_keypair(&keys.path().join("inline-key"), "");
3170 let blob = std::fs::read_to_string(&pub_key)
3171 .unwrap()
3172 .trim()
3173 .to_string();
3174
3175 for value in [format!("key::{blob}"), blob.clone()] {
3176 let repo = init_repo();
3177 let root = repo.path();
3178 git(root, &["config", "gpg.format", "ssh"]);
3179 git(root, &["config", "user.signingkey", &value]);
3180
3181 let result = check_signing_viability(root);
3182
3183 assert_eq!(
3184 result,
3185 SigningViability::Unknown {
3186 reason: INLINE_REASON.into(),
3187 },
3188 "inline value {value:?} was not routed to the unprobed Unknown arm: {result:?}"
3189 );
3190 assert_no_leak(&result, keys.path());
3191 }
3192 }
3193}