git_xcrypt/commands/unlock.rs
1//! `git-xcrypt unlock` — make a cloned repository readable again.
2//!
3//! This is the command PRD US-01 is about: the code is on the new machine, the
4//! secrets are not, and one key file has to turn ciphertext in the working tree
5//! back into the bytes that were committed.
6//!
7//! Three properties shape the implementation.
8//!
9//! **The registration comes before the decryption.** `.git/config` is not
10//! versioned, so a clone has no driver; and the `* filter=git-xcrypt` line in
11//! `.gitattributes` is only there if whoever set the repository up committed
12//! that file. Both are repaired here, because git treats a missing attribute and
13//! an undefined driver identically — as no filter. Decrypting first would leave
14//! a window in which the working tree holds plaintext and git has no filter,
15//! where `git status` reports every secret as modified and the next `git add`
16//! stores it in the clear.
17//!
18//! **A wrong key changes nothing at all.** Every encrypted file is inspected —
19//! 38 bytes each, no decryption — before a single byte is written, and before
20//! the key is even installed. Discovering the mismatch on the fourth file out of
21//! ten would leave a working tree that is half readable and a repository holding
22//! a key that does not belong to it. The limit of that promise is worth naming:
23//! the check can only object to a key it has evidence against, so a working tree
24//! with no encrypted file in it accepts any key. That case gets a warning rather
25//! than a refusal, because proving it would mean scanning history, which is
26//! `status`'s job in S-06.
27//!
28//! **Interrupting it is survivable.** The files are converted in place, one at a
29//! time, so a run cut short leaves some plain and some not. That is recoverable
30//! only because each file says what it is in its own header: a second `unlock`
31//! skips what is already plain and finishes the rest. Working from the object
32//! database instead would have been no safer and would have missed every file
33//! that is not committed yet.
34//!
35//! Which files get decrypted is decided by the **header**, not by `.git-xcrypt`
36//! — the same rule the smudge path follows, and for the same reason. It is also
37//! what makes the result byte-identical to a checkout, which is what `git
38//! status` being clean afterwards actually proves.
39
40use std::fs;
41use std::io::{BufRead, Read as _, Write};
42use std::path::{Path, PathBuf};
43
44use zeroize::Zeroizing;
45
46use crate::crypto::format::{self, Header, KEY_ID_LEN, OVERHEAD};
47use crate::crypto::keyfile;
48use crate::git::config as gitconfig;
49use crate::git::repo::{Repo, git_spelling};
50use crate::rules::decide;
51use crate::rules::declaration::Config;
52use crate::{Error, Result};
53
54/// What `unlock` did.
55#[derive(Debug)]
56pub struct Report {
57 /// Fingerprint of the key the repository now holds.
58 pub key_id: [u8; KEY_ID_LEN],
59 /// A key file was written. False when the key was already in place.
60 pub key_imported: bool,
61 /// The filter registration was written or repaired.
62 pub config_written: bool,
63 /// The managed `.gitattributes` section was written or repaired.
64 pub attributes_written: bool,
65 /// Paths, relative to the working tree, that were converted.
66 pub decrypted: Vec<PathBuf>,
67 /// Paths that could not be read, so may still be encrypted.
68 ///
69 /// Separate from [`Report::warnings`] because the count belongs in the
70 /// closing line: "decrypted 3 files" and "decrypted 3 files, 1 could not be
71 /// read" are different outcomes and must not look the same.
72 pub unreadable: Vec<PathBuf>,
73 /// Anything worth saying once, carried out so the binary owns the messages.
74 pub warnings: Vec<String>,
75}
76
77/// Where the key comes from, when one is offered at all.
78///
79/// A type rather than two `Option`s so the two cannot both be set, and so the
80/// call site reads as the choice it is. Both go through the same parser and the
81/// same refusals; only the reading differs.
82#[derive(Debug, Clone, Copy)]
83pub enum KeySource<'a> {
84 /// A file written by `export-key`.
85 File(&'a Path),
86 /// The text of such a file, handed over directly.
87 ///
88 /// **Visible in the process list for as long as the command runs, and kept
89 /// for ever in the shell's history** — measured on macOS: `ps -ww -o command
90 /// -p <pid>` prints the material verbatim. That is the price of the one
91 /// thing a file cannot do, which is arrive from a CI secret without ever
92 /// being written to disk, and it is the caller's to pay knowingly. The
93 /// binary says so on `stderr` every time.
94 Material(&'a str),
95}
96
97/// Room for the export a key file actually is, so the buffer never grows.
98///
99/// The text below holds the master key in the clear, and [`Zeroizing`] only
100/// protects a buffer that is never reallocated — a reallocation leaves the
101/// half-read key behind on the heap, where nothing wipes it. Today's export is
102/// 80 bytes (a 16-byte prefix, the version, a space, sixteen hex digits, then 44
103/// base64 characters, each line ending in `\n`); this is comfortably above it,
104/// and the same reasoning as `keyfile::encode_portable`'s sizing.
105const KEY_ENTRY_ROOM: usize = 128;
106
107/// Reads the text of a key file from `input`, prompting on `output`.
108///
109/// This is what `unlock --key` uses. It exists for the two shapes a path cannot
110/// serve: a key pasted by hand, and a key arriving over a pipe from whatever
111/// holds secrets — `cat key | git-xcrypt unlock --key`. The text goes through
112/// [`keyfile::decode_portable`] exactly like a file's, so the header still
113/// verifies the material behind it.
114///
115/// **Entry ends at a blank line, or at end of input.** The blank line is what
116/// makes the interactive form usable: an export is two lines, and pressing Enter
117/// once more is easier to reach for than the end-of-file key, which is `Ctrl+Z`
118/// on Windows and `Ctrl+D` everywhere else. Leading blank lines are skipped
119/// rather than treated as the end, because a key travelling through a password
120/// manager or an email body picks them up — the same tolerance
121/// `keyfile::significant_lines` already grants a file. The recorded limit of
122/// that: a `#` comment counts as content here, so a comment followed by a blank
123/// line ends the entry before the key arrives. It fails closed — the parser
124/// says this is not a key file — and the one shape this command is pointed at,
125/// what `export-key` writes, has no comment in it.
126///
127/// **The terminal answer is an argument, not a question asked here**, for the
128/// reason `export_key::to_writer` splits the same way: a test cannot portably
129/// arrange a terminal, and both arms have to be reachable on all three
130/// platforms. It decides two things and neither is the parsing — whether to
131/// print a prompt at all, which would be noise in a CI log, and whether the key
132/// was echoed and is therefore sitting in the scrollback.
133///
134/// # Errors
135///
136/// [`Error::Io`] when the prompt cannot be shown or the input cannot be read,
137/// including input that is not UTF-8 — a key file is text, and the file route
138/// refuses the same shape.
139pub fn read_key_material<R: BufRead, W: Write>(
140 input: &mut R,
141 output: &mut W,
142 input_is_a_terminal: bool,
143) -> Result<Zeroizing<String>> {
144 if input_is_a_terminal {
145 writeln!(
146 output,
147 "Paste the contents of a file written by `export-key`, \
148 then press Enter on an empty line:"
149 )?;
150 output.flush()?;
151 }
152
153 let mut text = Zeroizing::new(String::with_capacity(KEY_ENTRY_ROOM));
154 let mut seen_content = false;
155 loop {
156 let start = text.len();
157 if input.read_line(&mut text)? == 0 {
158 // End of input. The pipe form ends here, and so does a terminal
159 // whose user pressed the end-of-file key instead of Enter.
160 break;
161 }
162 if text[start..].trim().is_empty() {
163 // Never part of the key, whichever side of the entry it falls on.
164 text.truncate(start);
165 if seen_content {
166 break;
167 }
168 } else {
169 seen_content = true;
170 }
171 }
172 // No assertion on the capacity here, unlike `keyfile::encode_portable`: what
173 // it sizes is a key of known length, and this is whatever the caller pasted.
174 // A longer paste reallocates and leaves a copy on the heap — the honest
175 // limit of this route, and not a reason to panic on someone's input.
176
177 if input_is_a_terminal {
178 // Only here. Over a pipe nothing was echoed, and a warning that is false
179 // half the time is one people learn to skip — the same reason the
180 // conversion gate in `filter.rs` is narrower than git's.
181 writeln!(
182 output,
183 "git-xcrypt: {}",
184 super::export_key::SCROLLBACK_WARNING
185 )?;
186 output.flush()?;
187 }
188 Ok(text)
189}
190
191/// Unlocks `repo`, optionally installing the key at `key_source` first.
192///
193/// With `key_only` the working tree is left exactly as it is: the key goes in,
194/// the filter and the managed section are repaired, and nothing is decrypted.
195/// That was a command of its own until 2026-08-06 — `import-key` — and it is a
196/// flag now because the two differed by this one step and by nothing else,
197/// while `unlock <key-file>` was already the path every message pointed at.
198/// The evidence check still runs, so a key the working tree's own headers
199/// contradict is refused here exactly as it is on the full path.
200///
201/// # Errors
202///
203/// [`Error::NoKey`] when no key is given and none is present. [`Error::Config`]
204/// when `.git-xcrypt` cannot be understood, or when the repository already holds
205/// a key other than the one offered — note that this second case is code `2`
206/// rather than the `4` a file-level mismatch reports, because the refusal comes
207/// from the repository's own key file and not from anything a header said.
208/// [`Error::Format`] when a file in the working tree belongs to another key.
209/// [`Error::Io`] on a read or write failure.
210pub fn run(repo: &Repo, key_source: Option<KeySource<'_>>, key_only: bool) -> Result<Report> {
211 let key = match key_source {
212 Some(source) => {
213 let key = match source {
214 KeySource::File(path) => keyfile::read_portable(path)?,
215 // The same parser, so the header still verifies the material
216 // behind it: a key truncated on its way through a clipboard or
217 // a CI variable is refused rather than installed.
218 KeySource::Material(text) => keyfile::decode_portable(text)?,
219 };
220 // Asked before anything is written: a refusal that has already
221 // installed a key has not refused.
222 refuse_on_conflict(repo, &key)?;
223 key
224 }
225 None => repo.load_key()?,
226 };
227 let key_id = key.key_id();
228
229 // Everything that must be readable before anything is written. `.git-xcrypt`
230 // is loaded here rather than after the key is installed, so a typo in it
231 // cannot leave a key behind on its way out.
232 let config = Config::load(&repo.xcrypt_config_path())?;
233 let git_config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
234 let autocrlf = gitconfig::get(&git_config, "core.autocrlf");
235 let core_eol = gitconfig::get(&git_config, "core.eol");
236
237 // Everything carrying our magic, and the key each one asks for. Gathered
238 // before the first write, so a mismatch costs nothing.
239 let mut walk = Walk::default();
240 let encrypted = collect_encrypted(repo, &mut walk)?;
241 refuse_foreign_keys(repo, &encrypted, &key_id)?;
242
243 let key_imported = install(repo, &key)?;
244 // Both before the decryption, never after — see the module comment. The
245 // attributes section matters as much as the registration: a driver with no
246 // `* filter=git-xcrypt` above it is never invoked, so git would store the
247 // plaintext this command is about to put in the working tree, with exit
248 // code 0 and no signal. Measured on git 2.55 in a clone whose origin never
249 // committed `.gitattributes`.
250 let config_written = super::init::register_driver(repo)?;
251 let attributes_written = crate::git::attributes::write_section(
252 &repo.attributes_path(),
253 // Whichever spelling is already there: repairing this section must not
254 // silently undo a `sync --ignorecase`.
255 &crate::git::attributes::render_lines_as_written(&repo.attributes_path(), &config),
256 )?;
257
258 let mut report = Report {
259 key_id,
260 key_imported,
261 config_written,
262 attributes_written,
263 decrypted: Vec::new(),
264 unreadable: walk.unreadable,
265 warnings: config.pointless_eol.clone(),
266 };
267 report.warnings.append(&mut walk.warnings);
268
269 if config.missing {
270 // Not an error here — the headers say everything decryption needs — but
271 // the check-in path treats the same state as fatal, so without this the
272 // command would report success and leave a tree in which every `git add`
273 // aborts.
274 report.warnings.push(format!(
275 "{} is missing, so every `git add` in this repository will refuse \
276 until it is restored; run `git-xcrypt init` to create one",
277 crate::git::repo::CONFIG_FILE
278 ));
279 }
280
281 if key_imported && encrypted.is_empty() {
282 // The check above can only object to a key it has evidence against, and
283 // an empty working tree offers none. Saying so is the honest version of
284 // "a wrong key changes nothing": nothing was changed, but nothing
285 // confirmed the key either, and committing under the wrong one would
286 // split the repository's history across two keys.
287 report.warnings.push(format!(
288 "no encrypted file was found here, so nothing confirmed that key {} \
289 is this repository's. Run `git-xcrypt status` once the secrets are \
290 checked out.",
291 crate::format_key_id(&key_id)
292 ));
293 }
294 if key_only {
295 // Everything above is "put this repository in a state where git filters
296 // it"; everything below is "and now write the plain text out". Stopping
297 // here is the whole difference, and it is deliberately *after* the
298 // evidence check and both repairs: a key handed to a repository whose
299 // filter is not registered is not a safe place to leave anyone, whether
300 // or not the tree was decrypted on the way.
301 return Ok(report);
302 }
303
304 // The same paths, spelled the way the index stores them.
305 let mut rewritten: Vec<Vec<u8>> = Vec::new();
306
307 // **The loop stops at the first failure, but does not return from here.**
308 // Every step below used to be a bare `?`, which dropped the whole report
309 // together with the list of files already decrypted — and with it the stat
310 // refresh underneath. Measured, on a clone whose second declared file sat in
311 // a directory the user could not write: the first file was decrypted, the
312 // message was `i/o failure: Permission denied (os error 13)` naming nothing,
313 // and `git status` reported the decrypted file as modified for good, because
314 // a later run finds it already in the clear and so never refreshes it.
315 let mut stopped = None;
316 for file in &encrypted {
317 let relative = relative_to(repo, &file.path);
318 let name = repo_relative_bytes(&relative);
319 let content = match fs::read(&file.path) {
320 Ok(content) => content,
321 Err(err) => {
322 stopped = Some(named_io(&relative, "read", &err));
323 break;
324 }
325 };
326 let decision = config.decide(&name);
327
328 // The very function the smudge path calls, on purpose: anything else
329 // here would be a second implementation of line-ending handling, and the
330 // two would drift into a working tree git reports as modified.
331 let outcome = match decide::smudge(
332 Some(&key),
333 &name,
334 &content,
335 decision.encrypt,
336 decision.eol,
337 autocrlf.as_deref(),
338 core_eol.as_deref(),
339 ) {
340 Ok(outcome) => outcome,
341 Err(err) => {
342 stopped = Some(Error::Format(format!("{}: {err}", git_spelling(&relative))));
343 break;
344 }
345 };
346
347 if let Some(warning) = outcome.warning {
348 report.warnings.push(warning);
349 }
350
351 // Zeroizing: this is the secret, now in the clear on the heap.
352 let plaintext = Zeroizing::new(outcome.content);
353 if *plaintext == content {
354 // Unreachable for anything `collect_encrypted` yields — ciphertext
355 // is 38 bytes longer than its plaintext, so the two can never be
356 // equal. Skipping what is already plain happens one level up, in the
357 // walk; this is only here so a write can never be a no-op.
358 continue;
359 }
360 // Atomic, and inheriting the file's own mode, so an interruption cannot
361 // leave a half-written secret and an executable stays executable.
362 match crate::util::atomic::write(&file.path, &plaintext) {
363 Ok(()) => {}
364 Err(Error::Io(err)) => {
365 stopped = Some(named_io(&relative, "replace", &err));
366 break;
367 }
368 Err(err) => {
369 stopped = Some(err);
370 break;
371 }
372 }
373 rewritten.push(name);
374 report.decrypted.push(relative);
375 }
376
377 // Last, and not optional: without it git compares the new size against the
378 // one it cached for the ciphertext, concludes the file changed and never
379 // runs the filter to find out otherwise. `git status` would then report
380 // every unlocked secret as modified, for good. See `crate::git::index`.
381 //
382 // Run even when the loop stopped, and that is the point: the files already
383 // rewritten are the ones whose cached size is now wrong, and no later run
384 // will come back for them — they are plain text by then, so the walk does
385 // not select them at all.
386 let refreshed = crate::git::index::forget_stat(
387 &repo.git_dir().join("index"),
388 crate::git::index::object_hash(
389 gitconfig::get(&git_config, "extensions.objectformat").as_deref(),
390 ),
391 &rewritten,
392 );
393 match refreshed {
394 Ok(crate::git::index::Outcome::Cleared(_)) => {}
395 Ok(crate::git::index::Outcome::Skipped(why)) => report.warnings.push(why),
396 // A warning, not a return: the decryption already happened, and a bare
397 // `Err` here threw the whole report away — the user was never told that
398 // N files now sit in the clear, and a second run cannot say it either,
399 // because the files are plain by then and the walk no longer selects
400 // them. `Skipped` (a held lock, a split index) already answers the
401 // identical situation with a warning carrying the remedy; a failed read
402 // or write differs only in the errno. The report's own decrypted list
403 // is the load-bearing half — what changed on disk must reach the user
404 // whatever the stat cache did.
405 Err(err) if stopped.is_none() => report.warnings.push(format!(
406 "the index's stat cache could not be refreshed ({err}). The files \
407 are decrypted correctly; if `git status` shows them as modified, \
408 `git add --renormalize .` settles it."
409 )),
410 // A second failure on top of the one that stopped the loop. The first is
411 // what the user has to act on; this one goes with it rather than
412 // replacing it.
413 Err(err) => report.warnings.push(err.to_string()),
414 }
415
416 if let Some(err) = stopped {
417 return Err(interrupted(&report, &encrypted, err));
418 }
419
420 Ok(report)
421}
422
423/// Refuses when the repository already holds a key that is not this one.
424///
425/// Separate from [`install`] because this question has to be asked before
426/// anything at all is written: a refusal that has already installed a key has
427/// not refused.
428///
429/// # Errors
430///
431/// [`Error::Config`] for a different key, [`Error::Format`] when the key file
432/// already in the repository cannot be read.
433fn refuse_on_conflict(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<()> {
434 match repo.load_key() {
435 Ok(existing) if existing.key_id() == key.key_id() => Ok(()),
436 Ok(existing) => Err(Error::Config(format!(
437 "this repository already holds key {}, and that file offers key {}.\n\
438 Replacing it would make every file encrypted so far impossible to read, for good.\n\
439 If you really mean to change keys, remove {} deliberately first.",
440 crate::format_key_id(&existing.key_id()),
441 crate::format_key_id(&key.key_id()),
442 repo.key_path().display()
443 ))),
444 Err(Error::NoKey) => Ok(()),
445 // A key file we cannot parse is not evidence of absence. Naming it is
446 // the whole repair the user needs.
447 Err(Error::Format(message)) => Err(Error::Format(format!(
448 "{}: {message}",
449 repo.key_path().display()
450 ))),
451 Err(other) => Err(other),
452 }
453}
454
455/// Writes `key` into the repository, reporting whether it had to.
456///
457/// Only correct after [`refuse_on_conflict`] has passed: on its own it would
458/// treat a *different* key already in place as "nothing to do".
459///
460/// # Errors
461///
462/// [`Error::Io`] when the key file cannot be written.
463fn install(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<bool> {
464 if repo.has_key() {
465 return Ok(false);
466 }
467 keyfile::write(&repo.key_path(), key)?;
468 Ok(true)
469}
470
471/// Puts a path and the operation in front of a bare I/O failure.
472///
473/// `Permission denied (os error 13)` names neither the file nor what was being
474/// done to it, which for a command part way through rewriting a working tree is
475/// the least useful message it could produce. Measured before this: a `unlock`
476/// stopped by one unwritable directory said exactly that and nothing else.
477fn named_io(relative: &Path, action: &str, err: &std::io::Error) -> Error {
478 Error::Io(std::io::Error::other(format!(
479 "{}: could not {action} it ({err})",
480 git_spelling(relative)
481 )))
482}
483
484/// Adds what was already done to an error that stopped the decryption pass.
485///
486/// The bare error drops the report, and with it the only record that part of the
487/// working tree is now in the clear and part of it is not. The same shape `lock`
488/// uses for the same reason — and, unlike `lock`, this one has to say that a
489/// second run will *not* revisit what already succeeded, because a file in the
490/// clear no longer carries the magic the walk selects on.
491fn interrupted(report: &Report, encrypted: &[Encrypted], err: Error) -> Error {
492 let done = report.decrypted.len();
493 let left = encrypted.len().saturating_sub(done);
494 let context = format!(
495 "\nunlock stopped part way: {done} file(s) are now in the clear and {left} \
496 are still encrypted. The key is in place, so running unlock again picks up \
497 the rest once the cause above is fixed."
498 );
499 match err {
500 Error::Format(message) => Error::Format(message + &context),
501 Error::Crypto(message) => Error::Crypto(message + &context),
502 Error::Config(message) => Error::Config(message + &context),
503 Error::Io(err) => Error::Io(std::io::Error::other(format!("{err}{context}"))),
504 other => other,
505 }
506}
507
508/// A working-tree file that carries our magic, and the header it carries.
509#[derive(Debug)]
510pub(super) struct Encrypted {
511 path: PathBuf,
512 header: Header,
513}
514
515/// Every encrypted file in the working tree, in a stable order.
516///
517/// Only the first 38 bytes of each file are read, so the cost is one open per
518/// file rather than one full read — the same reasoning that lets `status` scan a
519/// whole history cheaply. The walk is otherwise exhaustive: it has no notion of
520/// `.gitignore`, so it does descend `target/` and `node_modules/`. That is the
521/// price of deciding by header, and it buys the case that matters — an encrypted
522/// file that no current pattern selects still gets decrypted, exactly as a
523/// checkout would decrypt it.
524///
525/// Untracked files are included for the same reason, and the bootstrap
526/// exclusions (`.gitattributes`, `.git-xcrypt`) are not consulted: a file
527/// carrying our magic is one of ours whatever its name, and leaving it as
528/// ciphertext would be the surprise.
529///
530/// A path that cannot be read becomes a warning rather than a failure. One
531/// root-owned build artefact must not be able to stop a user recovering their
532/// secrets, and skipping a file only ever means leaving it encrypted — but the
533/// skipped paths are counted and reported, because "decrypted everything" and
534/// "decrypted what it could" must not read the same.
535///
536/// Symbolic links are left alone: following one would write outside the
537/// repository, and replacing it would destroy the link.
538///
539/// **A directory holding a `.git` entry is another repository and is not
540/// entered.** Skipping the entry named `.git` is not enough — that leaves the
541/// submodule's *working tree* in the walk, and a submodule encrypted with its
542/// own key then makes the parent's `unlock` fail with a key mismatch it cannot
543/// be talked out of, having decrypted nothing. Measured. A submodule has its own
544/// configuration, its own key and its own index; it needs its own `unlock`.
545pub(super) fn collect_encrypted(repo: &Repo, walk: &mut Walk) -> Result<Vec<Encrypted>> {
546 let mut found = Vec::new();
547 let mut pending = vec![repo.work_tree().to_path_buf()];
548
549 while let Some(directory) = pending.pop() {
550 let entries = match fs::read_dir(&directory) {
551 Ok(entries) => entries,
552 Err(err) => {
553 walk.warnings
554 .push(format!("{}: not searched ({err})", directory.display()));
555 continue;
556 }
557 };
558
559 for entry in entries {
560 let entry = match entry {
561 Ok(entry) => entry,
562 Err(err) => {
563 walk.warnings
564 .push(format!("{}: not searched ({err})", directory.display()));
565 continue;
566 }
567 };
568 if entry.file_name() == ".git" {
569 continue;
570 }
571
572 let path = entry.path();
573 let Ok(metadata) = fs::symlink_metadata(&path) else {
574 walk.warnings
575 .push(format!("{}: skipped, it could not be read", path.display()));
576 walk.unreadable.push(relative_to(repo, &path));
577 continue;
578 };
579 if metadata.is_symlink() {
580 continue;
581 }
582 if metadata.is_dir() {
583 if path.join(".git").exists() {
584 walk.warnings.push(format!(
585 "{}: a repository of its own, left to its own `git-xcrypt unlock`",
586 git_spelling(&relative_to(repo, &path))
587 ));
588 } else {
589 pending.push(path);
590 }
591 continue;
592 }
593 if !metadata.is_file() {
594 continue;
595 }
596
597 match peek_header(&path) {
598 // A file whose header will not parse is one of ours and broken;
599 // that has to stop the run, unlike a file we simply cannot open.
600 Ok(Some(header)) => found.push(Encrypted { path, header }),
601 Ok(None) => {}
602 Err(Error::Io(err)) => {
603 walk.warnings
604 .push(format!("{}: skipped ({err})", path.display()));
605 walk.unreadable.push(relative_to(repo, &path));
606 }
607 Err(err) => return Err(err),
608 }
609 }
610 }
611
612 found.sort_by(|left, right| left.path.cmp(&right.path));
613 Ok(found)
614}
615
616/// What the walk noticed on its way through, besides the files it found.
617#[derive(Debug, Default)]
618pub(super) struct Walk {
619 /// Paths that could not be read, so may still hold ciphertext.
620 unreadable: Vec<PathBuf>,
621 /// Messages for the user, one per thing skipped.
622 pub(super) warnings: Vec<String>,
623}
624
625/// A path relative to the working tree, or the path itself if it is outside.
626fn relative_to(repo: &Repo, path: &Path) -> PathBuf {
627 repo.relative(path)
628 .map_or_else(|| path.to_path_buf(), Path::to_path_buf)
629}
630
631/// Reads the header of `path`, or `None` when the file is not one of ours.
632///
633/// A file that starts with our magic but is too short to hold a header is an
634/// error rather than a shrug: it is a truncated encrypted file, and carrying on
635/// would mean deciding it is plaintext.
636fn peek_header(path: &Path) -> Result<Option<Header>> {
637 let mut file = fs::File::open(path)?;
638 let mut prefix = [0u8; OVERHEAD];
639 let read = fill(&mut file, &mut prefix)?;
640 let prefix = &prefix[..read];
641
642 if !format::looks_encrypted(prefix) {
643 return Ok(None);
644 }
645
646 Header::parse(prefix)
647 .map(Some)
648 .map_err(|err| Error::Format(format!("{}: {err}", path.display())))
649}
650
651/// Reads until `buffer` is full or the file ends, returning how much arrived.
652///
653/// `Interrupted` is retried rather than reported, the way `std`'s own readers
654/// do: a signal arriving during a 38-byte read is not a reason to abandon a
655/// user's repository half unlocked.
656fn fill(file: &mut fs::File, buffer: &mut [u8]) -> std::io::Result<usize> {
657 let mut filled = 0;
658 while filled < buffer.len() {
659 match file.read(&mut buffer[filled..]) {
660 Ok(0) => break,
661 Ok(read) => filled += read,
662 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
663 Err(err) => return Err(err),
664 }
665 }
666 Ok(filled)
667}
668
669/// Refuses when any file belongs to a key other than the one offered.
670///
671/// Deliberately an [`Error::Format`] rather than [`Error::KeyMismatch`]: both
672/// report exit code 4, and this one can name the file, which is what turns
673/// "authentication failed" into an instruction.
674pub(super) fn refuse_foreign_keys(
675 repo: &Repo,
676 encrypted: &[Encrypted],
677 key_id: &[u8; KEY_ID_LEN],
678) -> Result<()> {
679 for file in encrypted {
680 if file.header.key_id == *key_id {
681 continue;
682 }
683
684 let relative = relative_to(repo, &file.path);
685 return Err(Error::Format(format!(
686 "{} was encrypted with key {}, but the key offered here is {}.\n\
687 Nothing has been changed. Unlock this repository with the key whose id is {}.",
688 git_spelling(&relative),
689 crate::format_key_id(&file.header.key_id),
690 crate::format_key_id(key_id),
691 crate::format_key_id(&file.header.key_id)
692 )));
693 }
694 Ok(())
695}
696
697/// A repository-relative path as the pattern matcher expects it.
698///
699/// Bytes rather than text, and forward slashes: on Unix a path is an arbitrary
700/// byte string, and decoding it lossily would match a file under a name it does
701/// not have.
702fn repo_relative_bytes(relative: &Path) -> Vec<u8> {
703 #[cfg(unix)]
704 {
705 use std::os::unix::ffi::OsStrExt as _;
706 relative.as_os_str().as_bytes().to_vec()
707 }
708 #[cfg(not(unix))]
709 {
710 relative.to_string_lossy().replace('\\', "/").into_bytes()
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 /// The export shape every case below is a variation on.
719 const EXPORT: &str = "git-xcrypt-key-v1 fd2f0a5c2d19a55b\n\
720 KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=\n";
721
722 fn read(input: &str, terminal: bool) -> (String, String) {
723 let mut said = Vec::new();
724 let text = read_key_material(&mut input.as_bytes(), &mut said, terminal)
725 .expect("reading from a slice cannot fail");
726 (
727 text.to_string(),
728 String::from_utf8(said).expect("the prompt is text"),
729 )
730 }
731
732 /// Both terminators, because a pipe reaches one and a person reaches the
733 /// other, and the same text has to come out of each.
734 #[test]
735 fn entry_ends_at_a_blank_line_or_at_the_end_of_the_input() {
736 let (piped, _) = read(EXPORT, false);
737 assert_eq!(piped, EXPORT, "end of input did not end the entry");
738
739 // What a person types: the two lines, then Enter on an empty one. The
740 // blank line is not part of the key and must not reach the parser.
741 let (typed, _) = read(&format!("{EXPORT}\n"), true);
742 assert_eq!(typed, EXPORT, "the blank line was kept, or ate the key");
743
744 // Anything after the blank line belongs to whoever comes next — a
745 // second command reading the same pipe — and never to this key.
746 let (stopped, _) = read(&format!("{EXPORT}\nnot the key\n"), true);
747 assert_eq!(stopped, EXPORT, "reading ran past the blank line");
748
749 // The same two shapes spelled `\r\n`, which is what a Windows console
750 // hands a `read_line` and what a clipboard carries out of a password
751 // manager or an email — the very routes this entry form exists for.
752 // `str::trim` is what makes such a line read as blank; a terminator
753 // that only knew `\n` would run straight past it, and at a terminal
754 // that is a command which never returns. Asserted through the second
755 // shape rather than the first, because that one *fails*: the text that
756 // followed becomes a third significant line and the parser refuses the
757 // file for carrying more than one key.
758 let crlf = EXPORT.replace('\n', "\r\n");
759 let (typed_crlf, _) = read(&format!("{crlf}\r\n"), true);
760 assert_eq!(
761 typed_crlf, crlf,
762 "a CRLF blank line was kept, or ate the key"
763 );
764 let (stopped_crlf, _) = read(&format!("{crlf}\r\nnot the key\r\n"), true);
765 assert_eq!(stopped_crlf, crlf, "reading ran past a CRLF blank line");
766
767 for (shape, text) in [
768 ("piped", piped.as_str()),
769 ("typed", typed.as_str()),
770 ("stopped", stopped.as_str()),
771 ("typed with CRLF", typed_crlf.as_str()),
772 ("stopped at a CRLF blank line", stopped_crlf.as_str()),
773 ] {
774 keyfile::decode_portable(text)
775 .unwrap_or_else(|err| panic!("the {shape} entry does not parse: {err}"));
776 }
777 }
778
779 /// A key out of a password manager or an email body arrives padded.
780 ///
781 /// The file route already tolerates this — `keyfile::significant_lines`
782 /// skips blank lines wherever they fall — so the typed route refusing it
783 /// would make the same key readable one way and not the other.
784 #[test]
785 fn a_leading_blank_line_is_skipped_rather_than_read_as_the_end() {
786 let (text, _) = read(&format!("\n\n{EXPORT}\n"), true);
787 assert!(
788 keyfile::decode_portable(&text).is_ok(),
789 "a leading blank line ended the entry before the key: {text:?}"
790 );
791 }
792
793 /// The terminal arm, which `tests/second_machine.rs` cannot reach.
794 ///
795 /// A pty is a Unix mechanism and this rule holds on all three platforms, so
796 /// the answer comes in as an argument exactly as it does for
797 /// `export_key::to_writer`. Asserted in **both** directions: the warning is
798 /// only true when something was echoed, and one that fires over a pipe is a
799 /// warning people stop reading.
800 #[test]
801 fn only_an_echoing_terminal_is_told_the_key_is_in_the_scrollback() {
802 let (_, over_a_pipe) = read(EXPORT, false);
803 assert_eq!(
804 over_a_pipe, "",
805 "a pipe was prompted at, or warned about a scrollback it has not got"
806 );
807
808 let (_, at_a_terminal) = read(&format!("{EXPORT}\n"), true);
809 assert!(
810 at_a_terminal.contains("export-key"),
811 "nothing said what to paste: {at_a_terminal:?}"
812 );
813 assert!(
814 at_a_terminal.contains("scrollback"),
815 "the key was echoed and nothing said so: {at_a_terminal:?}"
816 );
817 assert!(
818 !at_a_terminal.contains("KioqKioq"),
819 "the prompt printed the key back: {at_a_terminal:?}"
820 );
821 }
822}