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
283
284
285
286
287
//! Per-account `gh` config directory bootstrap (#7166, owner ruling "do B").
//!
//! Why: `resolve_gh_account_env_with`'s `config_dir` arm is the ONLY selector
//! that actually discriminates between logged-in accounts on a keyring-backed
//! `gh` install (`core::gh_account`'s own #5851 rationale for demoting `-u`)
//! — but nothing built one automatically for the `--account <login>` clone
//! selector; #7166's round-3 fix could only verify a possibly-wrong `-u`
//! token, never select the right one on such a host without a manual
//! `--gh-config-dir` pin. This module builds and reuses ONE isolated config
//! dir per known account, so the selector works out of the box.
//!
//! Layout: `<tm state root>/gh-accounts/<login>/` — `resolve_gh_account_env_with`'s
//! own `config_dir` arm (`core/gh_account.rs`, #5851's PR) accepts ANY
//! operator-chosen directory and defines no per-account convention of its
//! own, so this is a fresh layout, not a pre-existing one being reused.
//! `<tm state root>` is `crate::core::paths::FrameworkPaths::default().root`
//! (`~/.trusty-mpm`) in production; every function here takes it as a
//! parameter so tests point it at a tempdir instead.
//!
//! What: [`ensure_account_config_dir`] is the pure(ish), fully-injectable
//! core (real filesystem I/O, but every path is a parameter — no ambient
//! `$HOME`/`GH_CONFIG_DIR` read); [`ensure_account_config_dir_default`] is
//! the production entry point, resolving both the state root and the
//! operator's own `gh` config dir the same way `gh` itself does
//! ([`crate::core::gh_account::gh_config_dir`]).
//!
//! On first use for a never-before-selected `login`: reads the OPERATOR's
//! `hosts.yml` (never mutated) to confirm `login` is actually logged in —
//! refusing, dir untouched, when it is not — copies `config.yml` if present,
//! and writes a NEW `hosts.yml` naming only `login`. No token is ever
//! copied: `gh`'s credential store keys by login in the OS keyring/git
//! credential store, so the freshly-written `hosts.yml` alone is enough for
//! `gh auth token` (run with `GH_CONFIG_DIR` pointed at this dir) to resolve
//! that account's own credential. `gh auth switch` is never called — the
//! isolation comes from a SEPARATE config dir, not from mutating the shared
//! one's active pointer.
//!
//! Test: `account_config_dir_tests.rs`.
use ;
/// Directory name under the tm state root holding one subdirectory per known
/// account.
///
/// Test: `ensure_account_config_dir_places_it_under_gh_accounts`.
pub const GH_ACCOUNTS_DIR_NAME: &str = "gh-accounts";
/// The `<state_root>/gh-accounts/<login>` path for `login`.
///
/// Why: split out so both [`ensure_account_config_dir`] and its callers can
/// name the SAME path without recomputing the join.
/// What: `state_root.join("gh-accounts").join(login)`.
/// Test: `ensure_account_config_dir_places_it_under_gh_accounts`.
pub
/// Reject a `login` value that would escape or corrupt the intended
/// `<state_root>/gh-accounts/<login>` join (#7166 review follow-up MEDIUM).
///
/// Why: `is_name_segment` (`bin/tm/commands/register_args.rs`) only restricts
/// the CHARACTER SET (alnum, `.`, `_`, `-`) — it does not reject the exact
/// strings `.`/`..`, so `--account ..` passes CLI validation and resolves to
/// `<state_root>/gh-accounts/..` = `<state_root>` itself. That this is
/// currently unexploitable is incidental to a check written for a different
/// purpose ([`crate::core::gh_account::GhAccountStatus::
/// canonical_logged_in_login`] requires an exact, case-insensitive match
/// against a real, already-logged-in `gh` account, and no GitHub login can be
/// `.`/`..`) — not a guard at THIS path-construction site.
/// What: refuses `login` when it is empty, exactly `.` or `..`, or contains a
/// path separator (`/` or `\`), independent of whatever the caller already
/// validated.
/// Test: `ensure_account_config_dir_refuses_dot`,
/// `ensure_account_config_dir_refuses_dotdot`,
/// `ensure_account_config_dir_refuses_empty`,
/// `ensure_account_config_dir_refuses_a_forward_slash`,
/// `ensure_account_config_dir_refuses_a_backslash`.
/// `true` when `path` exists and is ITSELF a symlink (#7166 review follow-up
/// LOW) — checked with `symlink_metadata`, which does NOT follow the link,
/// unlike the `is_file`/`exists` calls used elsewhere in this module.
/// Test: `ensure_account_config_dir_refuses_a_symlinked_dir`,
/// `ensure_account_config_dir_refuses_a_symlinked_hosts_yml`.
/// Restrict `dir` to owner-only access (#7166 review follow-up MEDIUM) —
/// `gh`'s own `hosts.yml` is written 0600 because it normally holds a token;
/// this directory never holds one, but it does reveal which GitHub accounts
/// are used with `tm` and copies the operator's real `config.yml` verbatim,
/// so it must not be left at the ambient umask on a shared host.
/// Test: `ensure_account_config_dir_sets_restrictive_permissions`.
/// Restrict a written file (`hosts.yml`/`config.yml`) to owner-only access
/// (#7166 review follow-up MEDIUM) — same rationale as
/// [`set_private_dir_permissions`].
/// Test: `ensure_account_config_dir_sets_restrictive_permissions`.
/// Ensure a per-account `gh` config dir exists for `login`, building it from
/// `operator_gh_config_dir` on first use.
///
/// Why: the injectable-paths seam this codebase uses throughout for `gh`/
/// filesystem I/O that cannot run hermetically against the REAL `$HOME` —
/// tests point both paths at tempdirs and a fake `operator_gh_config_dir`.
/// What: `<state_root>/gh-accounts/<login>/hosts.yml` existing already means
/// "reused untouched" — returns `Ok(dir)` immediately, no filesystem writes.
/// Otherwise reads `<operator_gh_config_dir>/hosts.yml`; a missing/unparseable
/// file, or one that does not name `login` (case-insensitively; matches
/// [`crate::core::gh_account::GhAccountStatus::canonical_logged_in_login`]'s
/// convention) is a refusal naming the `gh auth login` remedy — the directory
/// is NEVER created in that case. Otherwise creates the directory, copies
/// `config.yml` when the operator has one (absent is not an error — `gh`
/// tolerates a config dir with no `config.yml`), and writes a fresh
/// `hosts.yml` naming ONLY the canonical spelling of `login` (no token —
/// `gh`'s credential store keys by login independently of this file).
/// Test: `ensure_account_config_dir_builds_from_operator_hosts_yml`,
/// `ensure_account_config_dir_refuses_an_unknown_login`,
/// `ensure_account_config_dir_reuses_an_existing_dir_untouched`,
/// `ensure_account_config_dir_copies_config_yml_when_present`,
/// `ensure_account_config_dir_tolerates_a_missing_config_yml`.
pub
/// Render a `hosts.yml` naming exactly one github.com account.
///
/// Why: split out as a pure function so its exact shape — parseable by
/// [`crate::core::gh_account::parse_gh_account_status_from_hosts_yml`], the
/// SAME parser `gh` itself effectively mirrors — is asserted without any
/// filesystem I/O.
/// What: `github.com:\n users:\n <login>:\n user: <login>\n`.
/// No `git_protocol` line — `gh` defaults it, and omitting it keeps this
/// writer from having an opinion `gh`'s own `hosts.yml` schema does not
/// require.
/// Test: `render_single_account_hosts_yml_round_trips_through_the_parser`.
/// Production entry point: resolve the tm state root and the operator's own
/// `gh` config dir the same way `gh` itself does, then delegate to
/// [`ensure_account_config_dir`].
///
/// Why: the one non-test call site — every other function in this module
/// takes its paths as parameters specifically so this is the ONLY place that
/// reads `$HOME`/`GH_CONFIG_DIR`/`XDG_CONFIG_HOME`.
/// What: state root is
/// [`crate::core::paths::FrameworkPaths::default`]`().root` (`~/.trusty-mpm`);
/// the operator's `gh` config dir is
/// [`crate::core::gh_account::gh_config_dir`] (honours `GH_CONFIG_DIR` /
/// `XDG_CONFIG_HOME`, else `~/.config/gh`), or a synthetic
/// `~/.config/gh` when even `dirs::home_dir()` fails (matches
/// `gh_config_dir`'s own last-resort fallback shape).
/// Test: none directly (thin, ambient-environment wiring); the logic it
/// delegates to is covered by [`ensure_account_config_dir`]'s tests.
pub