1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
//! Recognise a persisted hook (or `statusLine`) command whose executable lives
//! in a Cargo build tree, whatever the binary is named (issue #7262).
//!
//! Why: [`super::is_mpm_hash_suffixed_artifact`] asks two questions at once —
//! is the stem one of ours, and is the path a `deps/` directory — and answers
//! "not ours" when either fails. #7244 wrote
//! `<repo>/target-7247/debug/deps/test_session_lifecycle-<hash> hook --pm-guard`
//! into a real project's `settings.json` seven times in one day: the stem is a
//! test binary's, so neither `tm hooks clean` nor `tm doctor` could see it, and
//! [`super::write_project_hooks`]'s strip-by-identity step added a correct
//! group BESIDE the broken one instead of replacing it. The corruption is not
//! about the stem. Nothing tm persists may ever point into a build tree, so
//! asking WHERE the executable lives — and nothing about its name — closes the
//! whole class, including stems nobody has produced yet.
//!
//! What: [`is_build_tree_hook_command`] and [`is_build_tree_statusline_command`]
//! split the command string at one of [`TM_HOOK_ARGV_TAILS`] /
//! [`STATUSLINE_ARGV_TAIL`] and ask
//! [`trusty_common::bin_resolve::is_ephemeral_build_path`] about the executable
//! half. [`super::is_mpm_hook_command`] consults the first, so the ONE
//! classifier every consumer already shares — the doctor probe, `tm hooks
//! clean`, and both writers' replace-by-identity strips — sees this shape
//! without a second predicate to keep in sync.
//!
//! [`repointed_hook_command`] and [`repointed_statusline_command`] are the
//! REPAIR half (#7262, reopened): they hand back the same command with its
//! executable replaced by the installed binary and its argv untouched, which is
//! what [`super::repoint`] writes back. Detection and repair share one split
//! ([`split_build_tree_command`]), so the repair can never rewrite a command the
//! probe would not name.
//!
//! **Repair is narrower than detection, deliberately.** Naming a dead artifact
//! costs a report line; rewriting one makes tm run on an event tm may never have
//! registered. So the repair asks a second question the probe does not — is this
//! executable one tm's OWN writer could have persisted ([`is_tm_writable_exe`])
//! — and declines a foreign stem such as `<build-tree>/mytool`. What it still
//! repairs is what #7244 actually wrote: a tm stem, or the hash-suffixed
//! `deps/` artifact `current_exe()` yields for a Cargo binary.
//!
//! **Where the line is drawn.** A build-tree path ALONE is not enough: a
//! project may legitimately register its own hook that happens to live under
//! `target/debug`, and tm must not delete another owner's entry. Both halves
//! must hold — the executable is in a Cargo build tree AND the argv after it is
//! one of the four shapes tm itself writes. A command tm never writes
//! (`<build-tree>/mytool --check`, `<build-tree>/tm lint`) is left untouched no
//! matter where it lives. The residual overlap is a foreign binary invoked with
//! tm's exact argv from inside a Cargo build tree; that command is already dead
//! (the artifact is deleted by the next `cargo build`), and `tm hooks clean`'s
//! dry run prints every matched command before `--force` removes anything.
//!
//! Test: `build_tree_tests.rs`.
use Path;
/// The `hook` sub-command argv tails tm persists into a settings file.
///
/// Why (#7262): the second half of the two-part test above. Each entry is a
/// shape one of tm's writers produces: `" hook"` is the six-event lifecycle
/// triad ([`super::mpm_hook_additions_with_exe`]), [`PM_GUARD_SUFFIX`] the PM
/// enforcement guard, [`DIVERT_CHECK_SUFFIX`] the #6887 bulk-read diversion
/// groups, and [`PROMPT_FEEDBACK_SUFFIX`] the #7688 prompt-feedback capture.
/// Anything else is not tm's to classify.
/// What: the four tails, matched as exact string suffixes so the whole prefix
/// is the executable path — spaces in that path included. They are mutually
/// exclusive: a command ending in [`PM_GUARD_SUFFIX`] cannot also end in
/// `" hook"`.
/// Test: `build_tree_hook_command_matches_every_tm_argv_shape`,
/// `pm_guard_and_divert_commands_end_in_a_known_argv_tail` (the drift guard, in
/// `session_launch::project_hooks_tests`).
const TM_HOOK_ARGV_TAILS: & = &;
/// The `statusLine.command` argv tail (`#4492`, `#7262`).
///
/// Why: `statusLine.command` is written by a different code path than the hook
/// groups (`session_launch::settings::resolve_statusline_binary_with`) and is
/// not repaired by the hooks writer, so it is deliberately NOT in
/// [`TM_HOOK_ARGV_TAILS`] — a strip that removed it would silently drop a key
/// no writer here puts back. Detection still has to name it, which is what this
/// separate tail is for.
/// What: the one tail `resolve_statusline_command` appends.
/// Test: `build_tree_statusline_command_flags_a_build_tree_binary`.
const STATUSLINE_ARGV_TAIL: &str = " statusline";
/// The `hook --divert-check` argv tail (#6887).
///
/// Why (#7262): `session_launch::divert_hooks` used to own this literal and
/// this module needs the same string. Defining it in the lower layer and
/// re-using it there keeps ONE definition, so the diversion shape cannot drift
/// out of [`TM_HOOK_ARGV_TAILS`] the way a copied literal would.
/// What: the suffix `divert_hook_groups` appends to the resolved binary.
/// Test: `is_project_managed_hook_command_recognises_divert_check`.
pub const DIVERT_CHECK_SUFFIX: &str = " hook --divert-check";
/// The `hook --pm-guard` argv tail (#1914, #7262).
///
/// Why: three modules read or write this exact shape — the writer
/// (`session_launch::settings::pm_guard_hook_value`), the strip's identity
/// predicate (`session_launch::project_hooks::is_project_managed_hook_command`),
/// and [`TM_HOOK_ARGV_TAILS`] here. Each spelled the literal itself, so a
/// change to the sub-flag would have silently split them: the writer would
/// persist a shape the strip no longer recognises, and every relaunch would
/// append a duplicate group instead of replacing one — the #2948 failure mode.
/// One definition in the lowest layer, re-used upward, is the same fix
/// [`DIVERT_CHECK_SUFFIX`] already applies to the sibling shape.
/// What: the literal suffix, including its leading space.
/// Test: `pm_guard_and_divert_commands_end_in_a_known_argv_tail` (the drift
/// guard, in `session_launch::project_hooks_tests`),
/// `build_tree_hook_command_matches_every_tm_argv_shape`.
pub const PM_GUARD_SUFFIX: &str = " hook --pm-guard";
/// The `hook --prompt-feedback` argv tail (#7688).
///
/// Why: the same one-definition rule [`DIVERT_CHECK_SUFFIX`] and
/// [`PM_GUARD_SUFFIX`] already follow — the writer
/// (`session_launch::prompt_feedback_hooks::prompt_feedback_hook_groups`), the
/// strip's identity predicate, and [`TM_HOOK_ARGV_TAILS`] here all read this
/// exact string, and a copied literal would let them drift into the #2948
/// duplicate-group failure.
/// What: the literal suffix, including its leading space.
/// Test: `is_project_managed_hook_command_recognises_prompt_feedback`,
/// `pm_guard_and_divert_commands_end_in_a_known_argv_tail`.
pub const PROMPT_FEEDBACK_SUFFIX: &str = " hook --prompt-feedback";
/// Does `cmd` invoke a hook from a binary inside a Cargo build tree?
///
/// Why (#7262): the predicate [`super::is_mpm_hook_command`] consults so every
/// consumer — the `tm doctor` hook-hygiene probe, `tm hooks clean`, and the
/// replace-by-identity strip in both writers — classifies the #7244 corruption
/// identically, without any of them growing its own copy of the rule.
/// What: `true` when `cmd` ends with one of [`TM_HOOK_ARGV_TAILS`] and the
/// remaining prefix is an ABSOLUTE path that
/// [`trusty_common::bin_resolve::is_ephemeral_build_path`] rejects (any
/// `target*/{debug,release}` layout, a `CARGO_TARGET_DIR` tree, a
/// `.claude/worktrees/` checkout, or a system temp root). A relative prefix is
/// never matched: tm always persists an absolute path, so a bare `tm hook`
/// belongs to the exact-name branch, not this one.
/// Test: `build_tree_hook_command_flags_the_incident_shape`,
/// `build_tree_hook_command_matches_every_tm_argv_shape`,
/// `build_tree_hook_command_ignores_an_installed_binary`,
/// `build_tree_hook_command_ignores_a_foreign_argv_shape`.
/// Does `cmd` invoke `statusline` from a binary inside a Cargo build tree?
///
/// Why (#7262, #4492): the `statusLine.command` half of the same corruption.
/// `tm doctor` must NAME it — an operator whose statusline points at a deleted
/// test harness sees a blank segment and no error anywhere — but the hooks
/// writer does not own that key, so nothing here removes it.
/// What: [`is_build_tree_hook_command`]'s rule against
/// [`STATUSLINE_ARGV_TAIL`] instead of the hook tails.
/// Test: `build_tree_statusline_command_flags_a_build_tree_binary`,
/// `build_tree_statusline_command_ignores_an_installed_binary`.
/// The same hook command with its executable half replaced by `installed`
/// (issue #7262).
///
/// Why: removing a corrupted hook entry and repointing it are different
/// outcomes. Removal takes PM enforcement offline until the project's next
/// managed launch; repointing keeps the entry the operator already has and
/// fixes the one thing wrong with it — the dead path. The argv is preserved
/// verbatim, so `--pm-guard` stays `--pm-guard`.
/// What: `Some(<installed><tail>)` when [`is_build_tree_hook_command`] claims
/// `cmd` AND [`is_tm_writable_exe`] claims its executable, where `<tail>` is the
/// matched entry of [`TM_HOOK_ARGV_TAILS`]; `None` for every command either
/// rejects, and for an `installed` path that is not valid UTF-8. It never
/// inspects `installed` further — the caller
/// ([`super::repoint::repoint_settings_file`]) validates it once, so a refusal
/// is reported rather than silently collapsing into "nothing to do".
/// Test: `repointed_hook_command_rewrites_every_argv_shape`,
/// `repointed_hook_command_declines_an_installed_binary`,
/// `repointed_hook_command_declines_a_foreign_build_tree_stem`.
/// The `statusLine.command` counterpart of [`repointed_hook_command`] (#7262).
///
/// Why: #7286 could only REPORT a build-tree `statusLine.command`, because the
/// hooks writer does not own that key and stripping it would leave the operator
/// with no statusline at all. Repointing has neither problem: the key keeps its
/// value and gains a working path.
/// What: as [`repointed_hook_command`], against [`STATUSLINE_ARGV_TAIL`].
/// Test: `repointed_statusline_command_rewrites_the_incident_shape`.
/// Shared rule behind both predicates above.
///
/// Why: the two differ only in which argv tails they accept; the path test is
/// the same question and must not be answered twice.
/// What: finds the first `tails` entry that is a suffix of `cmd`, then returns
/// whether the remaining prefix is an absolute build-tree path.
/// Shared rule behind both repointers above.
///
/// Why (#7262): a repoint that used its own notion of "is this the damage" could
/// rewrite a command the detector never flagged. Deriving the rewrite from the
/// SAME split the predicate answers with makes the two unable to disagree about
/// which commands are candidates. Repair then asks ONE more question detection
/// does not — see [`is_tm_writable_exe`] — so the set it rewrites is a subset of
/// the set the probe names, never a different set.
/// What: `Some(<installed><tail>)` when [`split_build_tree_command`] claims
/// `cmd` AND [`is_tm_writable_exe`] claims the executable half; `None`
/// otherwise, or when `installed` is not valid UTF-8.
/// Could tm's own writer have persisted `exe` as a hook command's executable?
///
/// Why (#7262 round 2): repair and detection are not the same question.
/// Detection names a dead build artifact whoever owns it, which costs the
/// operator a report line. Repair rewrites the command into `<installed tm>
/// …`, which starts invoking tm on an event tm may never have registered — a
/// project that builds its own `target/debug/mytool` and wires it with tm's
/// argv would come back pointing at tm. That is a worse outcome than the dead
/// path it replaces, so the rewrite requires the executable to be one tm's
/// writer could actually have produced.
/// What: `true` when the file name is a shipped tm stem
/// ([`super::is_mpm_bin_stem_path`], a Cargo `-<hexhash>` suffix tolerated), or
/// when the path is a hash-suffixed artifact under a `deps/` directory. That
/// second arm is what #7244 wrote: the pre-fix writer persisted `current_exe()`
/// of whatever harness was running, and Cargo places every such artifact at
/// `…/deps/<stem>-<hexhash>`. A hand-named binary sitting directly in a build
/// tree matches neither arm.
/// Test: `repointed_hook_command_declines_a_foreign_build_tree_stem`,
/// `repointed_hook_command_still_claims_what_tm_wrote`.
/// Split `cmd` into its build-tree executable and the tm argv tail after it.
///
/// Why: the predicate and the repointer ask the same question — is this
/// executable a dead build artifact invoked with one of tm's own argv shapes —
/// and only differ in what they do with the answer. One split, two consumers.
/// What: `Some((exe, tail))` for the first `tails` entry that is a suffix of
/// `cmd` whose remaining prefix is an ABSOLUTE path
/// [`trusty_common::bin_resolve::is_ephemeral_build_path`] claims. A relative
/// prefix is never matched: tm always persists an absolute path, so a bare
/// `tm hook` belongs to the exact-name branch, not this one.
// `pub(crate)` so the #7244 incident fixture below is written ONCE and shared
// by the cleanup, writer, and `tm doctor` regression tests (#7262).
pub