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::Read as _;
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/// Unlocks `repo`, optionally installing the key at `key_source` first.
98///
99/// With `key_only` the working tree is left exactly as it is: the key goes in,
100/// the filter and the managed section are repaired, and nothing is decrypted.
101/// That was a command of its own until 2026-08-06 — `import-key` — and it is a
102/// flag now because the two differed by this one step and by nothing else,
103/// while `unlock <key-file>` was already the path every message pointed at.
104/// The evidence check still runs, so a key the working tree's own headers
105/// contradict is refused here exactly as it is on the full path.
106///
107/// # Errors
108///
109/// [`Error::NoKey`] when no key is given and none is present. [`Error::Config`]
110/// when `.git-xcrypt` cannot be understood, or when the repository already holds
111/// a key other than the one offered — note that this second case is code `2`
112/// rather than the `4` a file-level mismatch reports, because the refusal comes
113/// from the repository's own key file and not from anything a header said.
114/// [`Error::Format`] when a file in the working tree belongs to another key.
115/// [`Error::Io`] on a read or write failure.
116pub fn run(repo: &Repo, key_source: Option<KeySource<'_>>, key_only: bool) -> Result<Report> {
117 let key = match key_source {
118 Some(source) => {
119 let key = match source {
120 KeySource::File(path) => keyfile::read_portable(path)?,
121 // The same parser, so the header still verifies the material
122 // behind it: a key truncated on its way through a clipboard or
123 // a CI variable is refused rather than installed.
124 KeySource::Material(text) => keyfile::decode_portable(text)?,
125 };
126 // Asked before anything is written: a refusal that has already
127 // installed a key has not refused.
128 refuse_on_conflict(repo, &key)?;
129 key
130 }
131 None => repo.load_key()?,
132 };
133 let key_id = key.key_id();
134
135 // Everything that must be readable before anything is written. `.git-xcrypt`
136 // is loaded here rather than after the key is installed, so a typo in it
137 // cannot leave a key behind on its way out.
138 let config = Config::load(&repo.xcrypt_config_path())?;
139 let git_config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
140 let autocrlf = gitconfig::get(&git_config, "core.autocrlf");
141 let core_eol = gitconfig::get(&git_config, "core.eol");
142
143 // Everything carrying our magic, and the key each one asks for. Gathered
144 // before the first write, so a mismatch costs nothing.
145 let mut walk = Walk::default();
146 let encrypted = collect_encrypted(repo, &mut walk)?;
147 refuse_foreign_keys(repo, &encrypted, &key_id)?;
148
149 let key_imported = install(repo, &key)?;
150 // Both before the decryption, never after — see the module comment. The
151 // attributes section matters as much as the registration: a driver with no
152 // `* filter=git-xcrypt` above it is never invoked, so git would store the
153 // plaintext this command is about to put in the working tree, with exit
154 // code 0 and no signal. Measured on git 2.55 in a clone whose origin never
155 // committed `.gitattributes`.
156 let config_written = super::init::register_driver(repo)?;
157 let attributes_written = crate::git::attributes::write_section(
158 &repo.attributes_path(),
159 // Whichever spelling is already there: repairing this section must not
160 // silently undo a `sync --ignorecase`.
161 &crate::git::attributes::render_lines_as_written(&repo.attributes_path(), &config),
162 )?;
163
164 let mut report = Report {
165 key_id,
166 key_imported,
167 config_written,
168 attributes_written,
169 decrypted: Vec::new(),
170 unreadable: walk.unreadable,
171 warnings: config.pointless_eol.clone(),
172 };
173 report.warnings.append(&mut walk.warnings);
174
175 if config.missing {
176 // Not an error here — the headers say everything decryption needs — but
177 // the check-in path treats the same state as fatal, so without this the
178 // command would report success and leave a tree in which every `git add`
179 // aborts.
180 report.warnings.push(format!(
181 "{} is missing, so every `git add` in this repository will refuse \
182 until it is restored; run `git-xcrypt init` to create one",
183 crate::git::repo::CONFIG_FILE
184 ));
185 }
186
187 if key_imported && encrypted.is_empty() {
188 // The check above can only object to a key it has evidence against, and
189 // an empty working tree offers none. Saying so is the honest version of
190 // "a wrong key changes nothing": nothing was changed, but nothing
191 // confirmed the key either, and committing under the wrong one would
192 // split the repository's history across two keys.
193 report.warnings.push(format!(
194 "no encrypted file was found here, so nothing confirmed that key {} \
195 is this repository's. Run `git-xcrypt status` once the secrets are \
196 checked out.",
197 crate::format_key_id(&key_id)
198 ));
199 }
200 if key_only {
201 // Everything above is "put this repository in a state where git filters
202 // it"; everything below is "and now write the plain text out". Stopping
203 // here is the whole difference, and it is deliberately *after* the
204 // evidence check and both repairs: a key handed to a repository whose
205 // filter is not registered is not a safe place to leave anyone, whether
206 // or not the tree was decrypted on the way.
207 return Ok(report);
208 }
209
210 // The same paths, spelled the way the index stores them.
211 let mut rewritten: Vec<Vec<u8>> = Vec::new();
212
213 // **The loop stops at the first failure, but does not return from here.**
214 // Every step below used to be a bare `?`, which dropped the whole report
215 // together with the list of files already decrypted — and with it the stat
216 // refresh underneath. Measured, on a clone whose second declared file sat in
217 // a directory the user could not write: the first file was decrypted, the
218 // message was `i/o failure: Permission denied (os error 13)` naming nothing,
219 // and `git status` reported the decrypted file as modified for good, because
220 // a later run finds it already in the clear and so never refreshes it.
221 let mut stopped = None;
222 for file in &encrypted {
223 let relative = relative_to(repo, &file.path);
224 let name = repo_relative_bytes(&relative);
225 let content = match fs::read(&file.path) {
226 Ok(content) => content,
227 Err(err) => {
228 stopped = Some(named_io(&relative, "read", &err));
229 break;
230 }
231 };
232 let decision = config.decide(&name);
233
234 // The very function the smudge path calls, on purpose: anything else
235 // here would be a second implementation of line-ending handling, and the
236 // two would drift into a working tree git reports as modified.
237 let outcome = match decide::smudge(
238 Some(&key),
239 &name,
240 &content,
241 decision.encrypt,
242 decision.eol,
243 autocrlf.as_deref(),
244 core_eol.as_deref(),
245 ) {
246 Ok(outcome) => outcome,
247 Err(err) => {
248 stopped = Some(Error::Format(format!("{}: {err}", git_spelling(&relative))));
249 break;
250 }
251 };
252
253 if let Some(warning) = outcome.warning {
254 report.warnings.push(warning);
255 }
256
257 // Zeroizing: this is the secret, now in the clear on the heap.
258 let plaintext = Zeroizing::new(outcome.content);
259 if *plaintext == content {
260 // Unreachable for anything `collect_encrypted` yields — ciphertext
261 // is 38 bytes longer than its plaintext, so the two can never be
262 // equal. Skipping what is already plain happens one level up, in the
263 // walk; this is only here so a write can never be a no-op.
264 continue;
265 }
266 // Atomic, and inheriting the file's own mode, so an interruption cannot
267 // leave a half-written secret and an executable stays executable.
268 match crate::util::atomic::write(&file.path, &plaintext) {
269 Ok(()) => {}
270 Err(Error::Io(err)) => {
271 stopped = Some(named_io(&relative, "replace", &err));
272 break;
273 }
274 Err(err) => {
275 stopped = Some(err);
276 break;
277 }
278 }
279 rewritten.push(name);
280 report.decrypted.push(relative);
281 }
282
283 // Last, and not optional: without it git compares the new size against the
284 // one it cached for the ciphertext, concludes the file changed and never
285 // runs the filter to find out otherwise. `git status` would then report
286 // every unlocked secret as modified, for good. See `crate::git::index`.
287 //
288 // Run even when the loop stopped, and that is the point: the files already
289 // rewritten are the ones whose cached size is now wrong, and no later run
290 // will come back for them — they are plain text by then, so the walk does
291 // not select them at all.
292 let refreshed = crate::git::index::forget_stat(
293 &repo.git_dir().join("index"),
294 crate::git::index::object_hash(
295 gitconfig::get(&git_config, "extensions.objectformat").as_deref(),
296 ),
297 &rewritten,
298 );
299 match refreshed {
300 Ok(crate::git::index::Outcome::Cleared(_)) => {}
301 Ok(crate::git::index::Outcome::Skipped(why)) => report.warnings.push(why),
302 // A warning, not a return: the decryption already happened, and a bare
303 // `Err` here threw the whole report away — the user was never told that
304 // N files now sit in the clear, and a second run cannot say it either,
305 // because the files are plain by then and the walk no longer selects
306 // them. `Skipped` (a held lock, a split index) already answers the
307 // identical situation with a warning carrying the remedy; a failed read
308 // or write differs only in the errno. The report's own decrypted list
309 // is the load-bearing half — what changed on disk must reach the user
310 // whatever the stat cache did.
311 Err(err) if stopped.is_none() => report.warnings.push(format!(
312 "the index's stat cache could not be refreshed ({err}). The files \
313 are decrypted correctly; if `git status` shows them as modified, \
314 `git add --renormalize .` settles it."
315 )),
316 // A second failure on top of the one that stopped the loop. The first is
317 // what the user has to act on; this one goes with it rather than
318 // replacing it.
319 Err(err) => report.warnings.push(err.to_string()),
320 }
321
322 if let Some(err) = stopped {
323 return Err(interrupted(&report, &encrypted, err));
324 }
325
326 Ok(report)
327}
328
329/// Refuses when the repository already holds a key that is not this one.
330///
331/// Separate from [`install`] because this question has to be asked before
332/// anything at all is written: a refusal that has already installed a key has
333/// not refused.
334///
335/// # Errors
336///
337/// [`Error::Config`] for a different key, [`Error::Format`] when the key file
338/// already in the repository cannot be read.
339fn refuse_on_conflict(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<()> {
340 match repo.load_key() {
341 Ok(existing) if existing.key_id() == key.key_id() => Ok(()),
342 Ok(existing) => Err(Error::Config(format!(
343 "this repository already holds key {}, and that file offers key {}.\n\
344 Replacing it would make every file encrypted so far impossible to read, for good.\n\
345 If you really mean to change keys, remove {} deliberately first.",
346 crate::format_key_id(&existing.key_id()),
347 crate::format_key_id(&key.key_id()),
348 repo.key_path().display()
349 ))),
350 Err(Error::NoKey) => Ok(()),
351 // A key file we cannot parse is not evidence of absence. Naming it is
352 // the whole repair the user needs.
353 Err(Error::Format(message)) => Err(Error::Format(format!(
354 "{}: {message}",
355 repo.key_path().display()
356 ))),
357 Err(other) => Err(other),
358 }
359}
360
361/// Writes `key` into the repository, reporting whether it had to.
362///
363/// Only correct after [`refuse_on_conflict`] has passed: on its own it would
364/// treat a *different* key already in place as "nothing to do".
365///
366/// # Errors
367///
368/// [`Error::Io`] when the key file cannot be written.
369fn install(repo: &Repo, key: &crate::crypto::key::MasterKey) -> Result<bool> {
370 if repo.has_key() {
371 return Ok(false);
372 }
373 keyfile::write(&repo.key_path(), key)?;
374 Ok(true)
375}
376
377/// Puts a path and the operation in front of a bare I/O failure.
378///
379/// `Permission denied (os error 13)` names neither the file nor what was being
380/// done to it, which for a command part way through rewriting a working tree is
381/// the least useful message it could produce. Measured before this: a `unlock`
382/// stopped by one unwritable directory said exactly that and nothing else.
383fn named_io(relative: &Path, action: &str, err: &std::io::Error) -> Error {
384 Error::Io(std::io::Error::other(format!(
385 "{}: could not {action} it ({err})",
386 git_spelling(relative)
387 )))
388}
389
390/// Adds what was already done to an error that stopped the decryption pass.
391///
392/// The bare error drops the report, and with it the only record that part of the
393/// working tree is now in the clear and part of it is not. The same shape `lock`
394/// uses for the same reason — and, unlike `lock`, this one has to say that a
395/// second run will *not* revisit what already succeeded, because a file in the
396/// clear no longer carries the magic the walk selects on.
397fn interrupted(report: &Report, encrypted: &[Encrypted], err: Error) -> Error {
398 let done = report.decrypted.len();
399 let left = encrypted.len().saturating_sub(done);
400 let context = format!(
401 "\nunlock stopped part way: {done} file(s) are now in the clear and {left} \
402 are still encrypted. The key is in place, so running unlock again picks up \
403 the rest once the cause above is fixed."
404 );
405 match err {
406 Error::Format(message) => Error::Format(message + &context),
407 Error::Crypto(message) => Error::Crypto(message + &context),
408 Error::Config(message) => Error::Config(message + &context),
409 Error::Io(err) => Error::Io(std::io::Error::other(format!("{err}{context}"))),
410 other => other,
411 }
412}
413
414/// A working-tree file that carries our magic, and the header it carries.
415#[derive(Debug)]
416pub(super) struct Encrypted {
417 path: PathBuf,
418 header: Header,
419}
420
421/// Every encrypted file in the working tree, in a stable order.
422///
423/// Only the first 38 bytes of each file are read, so the cost is one open per
424/// file rather than one full read — the same reasoning that lets `status` scan a
425/// whole history cheaply. The walk is otherwise exhaustive: it has no notion of
426/// `.gitignore`, so it does descend `target/` and `node_modules/`. That is the
427/// price of deciding by header, and it buys the case that matters — an encrypted
428/// file that no current pattern selects still gets decrypted, exactly as a
429/// checkout would decrypt it.
430///
431/// Untracked files are included for the same reason, and the bootstrap
432/// exclusions (`.gitattributes`, `.git-xcrypt`) are not consulted: a file
433/// carrying our magic is one of ours whatever its name, and leaving it as
434/// ciphertext would be the surprise.
435///
436/// A path that cannot be read becomes a warning rather than a failure. One
437/// root-owned build artefact must not be able to stop a user recovering their
438/// secrets, and skipping a file only ever means leaving it encrypted — but the
439/// skipped paths are counted and reported, because "decrypted everything" and
440/// "decrypted what it could" must not read the same.
441///
442/// Symbolic links are left alone: following one would write outside the
443/// repository, and replacing it would destroy the link.
444///
445/// **A directory holding a `.git` entry is another repository and is not
446/// entered.** Skipping the entry named `.git` is not enough — that leaves the
447/// submodule's *working tree* in the walk, and a submodule encrypted with its
448/// own key then makes the parent's `unlock` fail with a key mismatch it cannot
449/// be talked out of, having decrypted nothing. Measured. A submodule has its own
450/// configuration, its own key and its own index; it needs its own `unlock`.
451pub(super) fn collect_encrypted(repo: &Repo, walk: &mut Walk) -> Result<Vec<Encrypted>> {
452 let mut found = Vec::new();
453 let mut pending = vec![repo.work_tree().to_path_buf()];
454
455 while let Some(directory) = pending.pop() {
456 let entries = match fs::read_dir(&directory) {
457 Ok(entries) => entries,
458 Err(err) => {
459 walk.warnings
460 .push(format!("{}: not searched ({err})", directory.display()));
461 continue;
462 }
463 };
464
465 for entry in entries {
466 let entry = match entry {
467 Ok(entry) => entry,
468 Err(err) => {
469 walk.warnings
470 .push(format!("{}: not searched ({err})", directory.display()));
471 continue;
472 }
473 };
474 if entry.file_name() == ".git" {
475 continue;
476 }
477
478 let path = entry.path();
479 let Ok(metadata) = fs::symlink_metadata(&path) else {
480 walk.warnings
481 .push(format!("{}: skipped, it could not be read", path.display()));
482 walk.unreadable.push(relative_to(repo, &path));
483 continue;
484 };
485 if metadata.is_symlink() {
486 continue;
487 }
488 if metadata.is_dir() {
489 if path.join(".git").exists() {
490 walk.warnings.push(format!(
491 "{}: a repository of its own, left to its own `git-xcrypt unlock`",
492 git_spelling(&relative_to(repo, &path))
493 ));
494 } else {
495 pending.push(path);
496 }
497 continue;
498 }
499 if !metadata.is_file() {
500 continue;
501 }
502
503 match peek_header(&path) {
504 // A file whose header will not parse is one of ours and broken;
505 // that has to stop the run, unlike a file we simply cannot open.
506 Ok(Some(header)) => found.push(Encrypted { path, header }),
507 Ok(None) => {}
508 Err(Error::Io(err)) => {
509 walk.warnings
510 .push(format!("{}: skipped ({err})", path.display()));
511 walk.unreadable.push(relative_to(repo, &path));
512 }
513 Err(err) => return Err(err),
514 }
515 }
516 }
517
518 found.sort_by(|left, right| left.path.cmp(&right.path));
519 Ok(found)
520}
521
522/// What the walk noticed on its way through, besides the files it found.
523#[derive(Debug, Default)]
524pub(super) struct Walk {
525 /// Paths that could not be read, so may still hold ciphertext.
526 unreadable: Vec<PathBuf>,
527 /// Messages for the user, one per thing skipped.
528 pub(super) warnings: Vec<String>,
529}
530
531/// A path relative to the working tree, or the path itself if it is outside.
532fn relative_to(repo: &Repo, path: &Path) -> PathBuf {
533 repo.relative(path)
534 .map_or_else(|| path.to_path_buf(), Path::to_path_buf)
535}
536
537/// Reads the header of `path`, or `None` when the file is not one of ours.
538///
539/// A file that starts with our magic but is too short to hold a header is an
540/// error rather than a shrug: it is a truncated encrypted file, and carrying on
541/// would mean deciding it is plaintext.
542fn peek_header(path: &Path) -> Result<Option<Header>> {
543 let mut file = fs::File::open(path)?;
544 let mut prefix = [0u8; OVERHEAD];
545 let read = fill(&mut file, &mut prefix)?;
546 let prefix = &prefix[..read];
547
548 if !format::looks_encrypted(prefix) {
549 return Ok(None);
550 }
551
552 Header::parse(prefix)
553 .map(Some)
554 .map_err(|err| Error::Format(format!("{}: {err}", path.display())))
555}
556
557/// Reads until `buffer` is full or the file ends, returning how much arrived.
558///
559/// `Interrupted` is retried rather than reported, the way `std`'s own readers
560/// do: a signal arriving during a 38-byte read is not a reason to abandon a
561/// user's repository half unlocked.
562fn fill(file: &mut fs::File, buffer: &mut [u8]) -> std::io::Result<usize> {
563 let mut filled = 0;
564 while filled < buffer.len() {
565 match file.read(&mut buffer[filled..]) {
566 Ok(0) => break,
567 Ok(read) => filled += read,
568 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
569 Err(err) => return Err(err),
570 }
571 }
572 Ok(filled)
573}
574
575/// Refuses when any file belongs to a key other than the one offered.
576///
577/// Deliberately an [`Error::Format`] rather than [`Error::KeyMismatch`]: both
578/// report exit code 4, and this one can name the file, which is what turns
579/// "authentication failed" into an instruction.
580pub(super) fn refuse_foreign_keys(
581 repo: &Repo,
582 encrypted: &[Encrypted],
583 key_id: &[u8; KEY_ID_LEN],
584) -> Result<()> {
585 for file in encrypted {
586 if file.header.key_id == *key_id {
587 continue;
588 }
589
590 let relative = relative_to(repo, &file.path);
591 return Err(Error::Format(format!(
592 "{} was encrypted with key {}, but the key offered here is {}.\n\
593 Nothing has been changed. Unlock this repository with the key whose id is {}.",
594 git_spelling(&relative),
595 crate::format_key_id(&file.header.key_id),
596 crate::format_key_id(key_id),
597 crate::format_key_id(&file.header.key_id)
598 )));
599 }
600 Ok(())
601}
602
603/// A repository-relative path as the pattern matcher expects it.
604///
605/// Bytes rather than text, and forward slashes: on Unix a path is an arbitrary
606/// byte string, and decoding it lossily would match a file under a name it does
607/// not have.
608fn repo_relative_bytes(relative: &Path) -> Vec<u8> {
609 #[cfg(unix)]
610 {
611 use std::os::unix::ffi::OsStrExt as _;
612 relative.as_os_str().as_bytes().to_vec()
613 }
614 #[cfg(not(unix))]
615 {
616 relative.to_string_lossy().replace('\\', "/").into_bytes()
617 }
618}