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    // Everything the normal write path encrypts: loose objects, the index,
89    // refs and HEAD.
90    //
91    // Refs were excluded while `write_ref` stored them in the clear —
92    // encrypting them then produced files `read_ref` could not read, and
93    // `branch` and `show` broke. Now that both sides of refs go through the
94    // cipher, they migrate with the rest.
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    // Refs move into the encrypted index rather than being encrypted in place.
104    // A ref name is a filename, so leaving them loose would keep every branch
105    // and tag name readable however well the contents were encrypted.
106    let mut refs_encrypted = 0usize;
107    for prefix in ["heads", "tags", "remotes"] {
108        for reference in crate::core::list_refs(&repo, prefix).unwrap_or_default() {
109            crate::core::write_ref(
110                &repo,
111                &format!("{}/{}", prefix, reference.name),
112                &reference.hash,
113            )?;
114            refs_encrypted += 1;
115        }
116    }
117
118    // With the index written, the loose files are what leaks the names.
119    for path in files_under(&lit.join("refs")) {
120        fs::remove_file(&path)
121            .map_err(|e| format!("Failed to remove {}: {}", path.display(), e))?;
122    }
123
124    let head = lit.join("HEAD");
125    if head.exists() {
126        if encrypt_file(&head, &encryption)? {
127            refs_encrypted += 1;
128        } else {
129            already += 1;
130        }
131    }
132
133    let index = lit.join("index");
134    let index_encrypted = if index.exists() {
135        encrypt_file(&index, &encryption)?
136    } else {
137        false
138    };
139    if index.exists() && !index_encrypted {
140        already += 1;
141    }
142
143    // Packs last, so the objects written here are not walked again above.
144    //
145    // A pack written before encryption holds plain zlib payloads, and rewriting
146    // one in place would mean recomputing every entry offset and its index.
147    // Exploding it back to loose objects is simpler and self-correcting: they
148    // go through the encrypted store, and `gc` can pack them again afterwards.
149    let packs_dir = pack::packs_dir(&repo);
150    let packed = pack::load_all(&packs_dir);
151    let mut objects_unpacked = 0usize;
152    let mut packs_expanded = 0usize;
153
154    if !packed.is_empty() {
155        let plaintext = EncryptionManager::new(EncryptionConfig {
156            enabled: false,
157            ..config.clone()
158        });
159        let store = ObjectStore::new(&repo);
160
161        for (hash, (pack_path, offset)) in &packed {
162            // A pack already encrypted reads through the real manager instead.
163            let object: Object = pack::read_pack_object(pack_path, *offset, &plaintext)
164                .or_else(|_| pack::read_pack_object(pack_path, *offset, &encryption))
165                .map_err(|e| format!("Failed to read {} from its pack: {}", &hash[..8], e))?;
166
167            store.write(&object)?;
168            objects_unpacked += 1;
169        }
170
171        for entry in files_under(&packs_dir) {
172            fs::remove_file(&entry)
173                .map_err(|e| format!("Failed to remove {}: {}", entry.display(), e))?;
174            packs_expanded += 1;
175        }
176    }
177
178    Ok(MigrateEncryptionResponse {
179        objects_encrypted,
180        objects_unpacked,
181        refs_encrypted,
182        index_encrypted,
183        packs_expanded,
184        already_encrypted: already,
185        message: format!(
186            "Encrypted {} loose objects, {} unpacked from {} pack files and {} refs; \
187             index {}; {} already encrypted",
188            objects_encrypted,
189            objects_unpacked,
190            packs_expanded,
191            refs_encrypted,
192            if index_encrypted {
193                "encrypted"
194            } else {
195                "left as it was"
196            },
197            already
198        ),
199    })
200}