Skip to main content

lit/commands/
migrate_encryption.rs

1//! Encrypt a repository that was created before encryption was switched on.
2//!
3//! Turning `enabled = true` on an existing repository used to leave it
4//! unreadable: the index and objects already on disk carry no encryption
5//! header, so every command failed. This walks that content and encrypts it in
6//! place, which is the step that was missing.
7//!
8//! The walk is per file and idempotent. Anything already encrypted is left
9//! alone, so an interrupted run is finished by running it again rather than
10//! leaving the repository half-converted.
11
12use crate::core::{find_repo_root, Object};
13use crate::crypto::encryption::{EncryptionConfig, EncryptionManager};
14use crate::response::MigrateEncryptionResponse;
15use crate::storage::{pack, ObjectStore};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19/// First byte of anything this repository encrypted.
20const ENCRYPTION_VERSION: u8 = 1;
21
22/// Whether `data` has already been through the cipher.
23///
24/// The version byte alone would be a guess — a zlib stream could in principle
25/// begin with it — so the header is confirmed by actually decrypting.
26fn already_encrypted(data: &[u8], encryption: &EncryptionManager) -> bool {
27    data.first() == Some(&ENCRYPTION_VERSION) && encryption.decrypt(data).is_ok()
28}
29
30/// Encrypt one file in place unless it already is.
31///
32/// Returns whether anything was written. The temporary file and rename keep a
33/// crash from leaving a half-written object behind; the original stays intact
34/// until the replacement is complete.
35fn encrypt_file(
36    path: &Path,
37    encryption: &EncryptionManager,
38) -> Result<bool, crate::errors::LitError> {
39    let data = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
40
41    if already_encrypted(&data, encryption) {
42        return Ok(false);
43    }
44
45    let encrypted = encryption.encrypt(&data)?;
46    let temp = path.with_extension("migrating");
47    fs::write(&temp, &encrypted)
48        .map_err(|e| format!("Failed to write {}: {}", temp.display(), e))?;
49    fs::rename(&temp, path).map_err(|e| format!("Failed to replace {}: {}", path.display(), e))?;
50
51    Ok(true)
52}
53
54/// Every regular file under `dir`, if it exists.
55fn files_under(dir: &Path) -> Vec<PathBuf> {
56    if !dir.exists() {
57        return Vec::new();
58    }
59    walkdir::WalkDir::new(dir)
60        .into_iter()
61        .filter_map(|e| e.ok())
62        .filter(|e| e.file_type().is_file())
63        .map(|e| e.path().to_path_buf())
64        .collect()
65}
66
67/// Encrypt an existing repository in place.
68pub fn execute() -> Result<MigrateEncryptionResponse, crate::errors::LitError> {
69    let repo = find_repo_root()?;
70    let config = EncryptionConfig::load(&repo)?;
71
72    if !config.enabled {
73        return Err(
74            "Encryption is not enabled for this repository. Set enabled = true in \
75                    .lit/encryption.toml first."
76                .into(),
77        );
78    }
79
80    // Needs a real key: the whole job is writing ciphertext.
81    let encryption = EncryptionManager::new_auto(config.clone(), &repo);
82    encryption.encrypt(b"probe")?;
83
84    let lit = repo.join(".lit");
85    let mut objects_encrypted = 0usize;
86    let mut already = 0usize;
87
88    // Only what the normal write path encrypts: loose objects and the index.
89    //
90    // Refs and HEAD are deliberately left alone. `write_ref` and `update_head`
91    // store them in clear text — the `_encrypted` variants beside them have no
92    // callers — so encrypting them here would produce files that `read_ref` and
93    // `read_head` cannot read, and `branch` and `show` would start failing on a
94    // repository this command is supposed to repair.
95    for path in files_under(&lit.join("objects")) {
96        if encrypt_file(&path, &encryption)? {
97            objects_encrypted += 1;
98        } else {
99            already += 1;
100        }
101    }
102
103    let index = lit.join("index");
104    let index_encrypted = if index.exists() {
105        encrypt_file(&index, &encryption)?
106    } else {
107        false
108    };
109    if index.exists() && !index_encrypted {
110        already += 1;
111    }
112
113    // Packs last, so the objects written here are not walked again above.
114    //
115    // A pack written before encryption holds plain zlib payloads, and rewriting
116    // one in place would mean recomputing every entry offset and its index.
117    // Exploding it back to loose objects is simpler and self-correcting: they
118    // go through the encrypted store, and `gc` can pack them again afterwards.
119    let packs_dir = pack::packs_dir(&repo);
120    let packed = pack::load_all(&packs_dir);
121    let mut objects_unpacked = 0usize;
122    let mut packs_expanded = 0usize;
123
124    if !packed.is_empty() {
125        let plaintext = EncryptionManager::new(EncryptionConfig {
126            enabled: false,
127            ..config.clone()
128        });
129        let store = ObjectStore::new(&repo);
130
131        for (hash, (pack_path, offset)) in &packed {
132            // A pack already encrypted reads through the real manager instead.
133            let object: Object = pack::read_pack_object(pack_path, *offset, &plaintext)
134                .or_else(|_| pack::read_pack_object(pack_path, *offset, &encryption))
135                .map_err(|e| format!("Failed to read {} from its pack: {}", &hash[..8], e))?;
136
137            store.write(&object)?;
138            objects_unpacked += 1;
139        }
140
141        for entry in files_under(&packs_dir) {
142            fs::remove_file(&entry)
143                .map_err(|e| format!("Failed to remove {}: {}", entry.display(), e))?;
144            packs_expanded += 1;
145        }
146    }
147
148    Ok(MigrateEncryptionResponse {
149        objects_encrypted,
150        objects_unpacked,
151        index_encrypted,
152        packs_expanded,
153        already_encrypted: already,
154        message: format!(
155            "Encrypted {} loose objects and {} unpacked from {} pack files; index {}; \
156             {} already encrypted",
157            objects_encrypted,
158            objects_unpacked,
159            packs_expanded,
160            if index_encrypted {
161                "encrypted"
162            } else {
163                "left as it was"
164            },
165            already
166        ),
167    })
168}