Skip to main content

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}