Skip to main content

lit/core/
refs.rs

1use crate::crypto::encryption::EncryptionManager;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8/// Reference - points to a commit (branch, tag, HEAD)
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Reference {
11    pub name: String,
12    pub hash: String,
13}
14
15/// Get the Lit directory path
16pub fn get_lit_dir(repo_path: &Path) -> PathBuf {
17    repo_path.join(".lit")
18}
19
20/// The encryption manager guarding a repository's refs.
21///
22/// Built the same way the object store and index build theirs, so refs are
23/// covered by the same passphrase and the same non-interactive sources. When
24/// encryption is off the manager passes bytes through untouched.
25fn ref_encryption(repo_path: &Path) -> EncryptionManager {
26    let config = crate::crypto::encryption::EncryptionConfig::load(repo_path).unwrap_or_default();
27    EncryptionManager::new_auto(config, repo_path)
28}
29
30/// Write ref-shaped text, encrypted when the repository is.
31fn write_ref_file(path: &Path, repo_path: &Path, text: &str) -> Result<(), String> {
32    let data = ref_encryption(repo_path).encrypt(text.as_bytes())?;
33    fs::write(path, data).map_err(|e| format!("Failed to write reference: {}", e))
34}
35
36/// Read ref-shaped text, decrypting it when it was encrypted.
37///
38/// A ref written before encryption was switched on carries no header and is
39/// returned as it stands, so a repository that has not been migrated still
40/// reads. `migrate-encryption` converts them.
41fn read_ref_file(path: &Path, repo_path: &Path) -> Result<String, String> {
42    let data = fs::read(path).map_err(|e| format!("Failed to read reference: {}", e))?;
43
44    let plain = if EncryptionManager::is_encrypted_payload(&data) {
45        ref_encryption(repo_path).decrypt(&data)?
46    } else {
47        data
48    };
49
50    String::from_utf8(plain)
51        .map_err(|e| format!("Reference is not valid UTF-8: {}", e))
52        .map(|s| s.trim().to_string())
53}
54
55/// Where an encrypted repository keeps all of its refs.
56fn refs_index_path(repo_path: &Path) -> PathBuf {
57    get_lit_dir(repo_path).join("refs.enc")
58}
59
60/// Whether this repository encrypts at rest.
61fn encryption_enabled(repo_path: &Path) -> bool {
62    crate::crypto::encryption::EncryptionConfig::load(repo_path)
63        .map(|config| config.enabled)
64        .unwrap_or(false)
65}
66
67/// Load the encrypted ref index, empty when there is none yet.
68///
69/// A ref name is a filename, so a directory holding one file per ref leaks
70/// every branch and tag name however well the contents are encrypted.
71/// Collapsing them into a single encrypted map hides the names as well.
72///
73/// The cost is granularity: refs become read-modify-write as a unit, so two
74/// processes updating different branches at the same moment can race where
75/// separate files could not. That is why this is used only when encryption is
76/// on — an unencrypted repository keeps the directory and its concurrency.
77fn load_refs_index(repo_path: &Path) -> Result<BTreeMap<String, String>, String> {
78    let path = refs_index_path(repo_path);
79    if !path.exists() {
80        return Ok(BTreeMap::new());
81    }
82
83    let data = fs::read(&path).map_err(|e| format!("Failed to read ref index: {}", e))?;
84    let plain = if EncryptionManager::is_encrypted_payload(&data) {
85        ref_encryption(repo_path).decrypt(&data)?
86    } else {
87        data
88    };
89
90    serde_json::from_slice(&plain).map_err(|e| format!("Failed to parse ref index: {}", e))
91}
92
93/// Write the ref index back, encrypted.
94fn save_refs_index(repo_path: &Path, refs: &BTreeMap<String, String>) -> Result<(), String> {
95    let json =
96        serde_json::to_vec(refs).map_err(|e| format!("Failed to serialize ref index: {}", e))?;
97    let data = ref_encryption(repo_path).encrypt(&json)?;
98    fs::write(refs_index_path(repo_path), data)
99        .map_err(|e| format!("Failed to write ref index: {}", e))
100}
101
102/// Check if a directory is a Lit repository
103pub fn is_lit_repo(path: &Path) -> bool {
104    get_lit_dir(path).exists()
105}
106
107/// Find the repository root from current directory
108pub fn find_repo_root() -> Result<PathBuf, String> {
109    let mut current =
110        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
111
112    loop {
113        if is_lit_repo(&current) {
114            return Ok(current);
115        }
116
117        if !current.pop() {
118            return Err("Not in a Lit repository".to_string());
119        }
120    }
121}
122
123/// Read a reference file
124pub fn read_ref(repo_path: &Path, ref_name: &str) -> Result<String, String> {
125    // An encrypted repository keeps its refs in one file so the names are not
126    // exposed as directory entries. A repository that has not been migrated
127    // still has them loose, so fall through to that rather than failing.
128    if encryption_enabled(repo_path) {
129        if let Some(hash) = load_refs_index(repo_path)?.get(ref_name) {
130            return Ok(hash.clone());
131        }
132    }
133
134    let ref_path = get_lit_dir(repo_path).join("refs").join(ref_name);
135
136    if !ref_path.exists() {
137        return Err(format!("Reference '{}' not found", ref_name));
138    }
139
140    read_ref_file(&ref_path, repo_path)
141}
142
143/// Read an encrypted reference file
144pub fn read_ref_encrypted(
145    repo_path: &Path,
146    ref_name: &str,
147    encryption: &Arc<Mutex<EncryptionManager>>,
148) -> Result<String, String> {
149    let ref_path = get_lit_dir(repo_path).join("refs").join(ref_name);
150
151    if !ref_path.exists() {
152        return Err(format!("Reference '{}' not found", ref_name));
153    }
154
155    let encrypted_data =
156        fs::read(&ref_path).map_err(|e| format!("Failed to read reference: {}", e))?;
157
158    let enc_guard = encryption
159        .lock()
160        .map_err(|_| "Failed to lock encryption manager".to_string())?;
161
162    let decrypted = enc_guard.decrypt(&encrypted_data)?;
163
164    String::from_utf8(decrypted)
165        .map_err(|e| format!("Invalid UTF-8 in decrypted reference: {}", e))
166        .map(|s| s.trim().to_string())
167}
168
169/// Write a reference file
170pub fn write_ref(repo_path: &Path, ref_name: &str, hash: &str) -> Result<(), String> {
171    if encryption_enabled(repo_path) {
172        let mut refs = load_refs_index(repo_path)?;
173        refs.insert(ref_name.to_string(), hash.to_string());
174        return save_refs_index(repo_path, &refs);
175    }
176
177    let ref_path = get_lit_dir(repo_path).join("refs").join(ref_name);
178
179    if let Some(parent) = ref_path.parent() {
180        fs::create_dir_all(parent).map_err(|e| format!("Failed to create ref directory: {}", e))?;
181    }
182
183    write_ref_file(&ref_path, repo_path, &format!("{}\n", hash))
184}
185
186/// Write an encrypted reference file
187pub fn write_ref_encrypted(
188    repo_path: &Path,
189    ref_name: &str,
190    hash: &str,
191    encryption: &Arc<Mutex<EncryptionManager>>,
192) -> Result<(), String> {
193    let ref_path = get_lit_dir(repo_path).join("refs").join(ref_name);
194
195    if let Some(parent) = ref_path.parent() {
196        fs::create_dir_all(parent).map_err(|e| format!("Failed to create ref directory: {}", e))?;
197    }
198
199    let data = format!("{}\n", hash);
200
201    let enc_guard = encryption
202        .lock()
203        .map_err(|_| "Failed to lock encryption manager".to_string())?;
204
205    let encrypted = enc_guard.encrypt(data.as_bytes())?;
206    drop(enc_guard);
207
208    fs::write(&ref_path, encrypted).map_err(|e| format!("Failed to write reference: {}", e))
209}
210
211/// Delete a reference
212pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> {
213    let ref_path = get_lit_dir(repo_path).join("refs").join(ref_name);
214
215    // Remove from both, since a part-migrated repository may hold it in either.
216    let mut removed = false;
217
218    if encryption_enabled(repo_path) {
219        let mut refs = load_refs_index(repo_path)?;
220        if refs.remove(ref_name).is_some() {
221            save_refs_index(repo_path, &refs)?;
222            removed = true;
223        }
224    }
225
226    if ref_path.exists() {
227        fs::remove_file(&ref_path).map_err(|e| format!("Failed to delete reference: {}", e))?;
228        removed = true;
229    }
230
231    if removed {
232        Ok(())
233    } else {
234        Err(format!("Reference '{}' not found", ref_name))
235    }
236}
237
238/// List all references
239pub fn list_refs(repo_path: &Path, prefix: &str) -> Result<Vec<Reference>, String> {
240    let refs_dir = get_lit_dir(repo_path).join("refs").join(prefix);
241    let mut refs = Vec::new();
242    let mut seen = std::collections::HashSet::new();
243
244    // Encrypted repositories hold their refs in the index; a repository that
245    // has not been migrated may still have loose files, so take both and let
246    // the index win.
247    if encryption_enabled(repo_path) {
248        let with_slash = format!("{}/", prefix);
249        for (name, hash) in load_refs_index(repo_path)? {
250            if let Some(short) = name.strip_prefix(&with_slash) {
251                seen.insert(short.to_string());
252                refs.push(Reference {
253                    name: short.to_string(),
254                    hash,
255                });
256            }
257        }
258    }
259
260    if !refs_dir.exists() {
261        return Ok(refs);
262    }
263
264    for entry in walkdir::WalkDir::new(&refs_dir) {
265        let entry = entry.map_err(|e| format!("Failed to read refs: {}", e))?;
266
267        if entry.file_type().is_file() {
268            let path = entry.path();
269            let name = path
270                .strip_prefix(&refs_dir)
271                .map_err(|e| format!("Path error: {}", e))?
272                .to_string_lossy()
273                .to_string();
274
275            if seen.contains(&name) {
276                continue;
277            }
278
279            let hash = read_ref_file(path, repo_path)?;
280
281            refs.push(Reference { name, hash });
282        }
283    }
284
285    Ok(refs)
286}
287
288/// Read HEAD reference
289pub fn read_head(repo_path: &Path) -> Result<String, String> {
290    let head_path = get_lit_dir(repo_path).join("HEAD");
291
292    if !head_path.exists() {
293        return Err("HEAD not found".to_string());
294    }
295
296    let content = read_ref_file(&head_path, repo_path)?;
297    let content = content.trim();
298
299    // Check if HEAD is symbolic (ref: refs/heads/main)
300    if let Some(ref_name) = content.strip_prefix("ref: ") {
301        read_ref(
302            repo_path,
303            ref_name.strip_prefix("refs/").unwrap_or(ref_name),
304        )
305    } else {
306        // Direct hash
307        Ok(content.to_string())
308    }
309}
310
311/// Get current branch name
312pub fn get_current_branch(repo_path: &Path) -> Result<String, String> {
313    let head_path = get_lit_dir(repo_path).join("HEAD");
314
315    let content = read_ref_file(&head_path, repo_path)?;
316    let content = content.trim();
317
318    if let Some(branch) = content.strip_prefix("ref: refs/heads/") {
319        Ok(branch.to_string())
320    } else {
321        Err("HEAD is detached".to_string())
322    }
323}
324
325/// Update HEAD to point to a branch
326pub fn update_head(repo_path: &Path, branch: &str) -> Result<(), String> {
327    let head_path = get_lit_dir(repo_path).join("HEAD");
328
329    write_ref_file(
330        &head_path,
331        repo_path,
332        &format!("ref: refs/heads/{}\n", branch),
333    )
334}
335
336/// Update HEAD to point to a branch (encrypted)
337pub fn update_head_encrypted(
338    repo_path: &Path,
339    branch: &str,
340    encryption: &Arc<Mutex<EncryptionManager>>,
341) -> Result<(), String> {
342    let head_path = get_lit_dir(repo_path).join("HEAD");
343
344    let data = format!("ref: refs/heads/{}\n", branch);
345
346    let enc_guard = encryption
347        .lock()
348        .map_err(|_| "Failed to lock encryption manager".to_string())?;
349
350    let encrypted = enc_guard.encrypt(data.as_bytes())?;
351    drop(enc_guard);
352
353    fs::write(&head_path, encrypted).map_err(|e| format!("Failed to update HEAD: {}", e))
354}
355
356/// Set HEAD to a specific commit (detached)
357pub fn set_head_detached(repo_path: &Path, hash: &str) -> Result<(), String> {
358    let head_path = get_lit_dir(repo_path).join("HEAD");
359
360    write_ref_file(&head_path, repo_path, &format!("{}\n", hash))
361}
362
363/// Set HEAD to a specific commit (detached, encrypted)
364pub fn set_head_detached_encrypted(
365    repo_path: &Path,
366    hash: &str,
367    encryption: &Arc<Mutex<EncryptionManager>>,
368) -> Result<(), String> {
369    let head_path = get_lit_dir(repo_path).join("HEAD");
370
371    let data = format!("{}\n", hash);
372
373    let enc_guard = encryption
374        .lock()
375        .map_err(|_| "Failed to lock encryption manager".to_string())?;
376
377    let encrypted = enc_guard.encrypt(data.as_bytes())?;
378    drop(enc_guard);
379
380    fs::write(&head_path, encrypted).map_err(|e| format!("Failed to set HEAD: {}", e))
381}
382
383/// Read HEAD reference (encrypted)
384pub fn read_head_encrypted(
385    repo_path: &Path,
386    encryption: &Arc<Mutex<EncryptionManager>>,
387) -> Result<String, String> {
388    let head_path = get_lit_dir(repo_path).join("HEAD");
389
390    if !head_path.exists() {
391        return Err("HEAD not found".to_string());
392    }
393
394    let encrypted_data = fs::read(&head_path).map_err(|e| format!("Failed to read HEAD: {}", e))?;
395
396    let enc_guard = encryption
397        .lock()
398        .map_err(|_| "Failed to lock encryption manager".to_string())?;
399
400    let decrypted = enc_guard.decrypt(&encrypted_data)?;
401    drop(enc_guard);
402
403    let content = String::from_utf8(decrypted)
404        .map_err(|e| format!("Invalid UTF-8 in decrypted HEAD: {}", e))?;
405
406    let content = content.trim();
407
408    // Check if HEAD is symbolic (ref: refs/heads/main)
409    if let Some(ref_name) = content.strip_prefix("ref: ") {
410        read_ref_encrypted(
411            repo_path,
412            ref_name.strip_prefix("refs/").unwrap_or(ref_name),
413            encryption,
414        )
415    } else {
416        // Direct hash
417        Ok(content.to_string())
418    }
419}
420
421/// Get current branch name (encrypted)
422pub fn get_current_branch_encrypted(
423    repo_path: &Path,
424    encryption: &Arc<Mutex<EncryptionManager>>,
425) -> Result<String, String> {
426    let head_path = get_lit_dir(repo_path).join("HEAD");
427
428    let encrypted_data = fs::read(&head_path).map_err(|e| format!("Failed to read HEAD: {}", e))?;
429
430    let enc_guard = encryption
431        .lock()
432        .map_err(|_| "Failed to lock encryption manager".to_string())?;
433
434    let decrypted = enc_guard.decrypt(&encrypted_data)?;
435    drop(enc_guard);
436
437    let content = String::from_utf8(decrypted)
438        .map_err(|e| format!("Invalid UTF-8 in decrypted HEAD: {}", e))?;
439
440    let content = content.trim();
441
442    if let Some(branch) = content.strip_prefix("ref: refs/heads/") {
443        Ok(branch.to_string())
444    } else {
445        Err("HEAD is detached".to_string())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::crypto::encryption::EncryptionConfig;
453    use tempfile::TempDir;
454
455    #[test]
456    fn test_encrypted_ref_write_read() {
457        let temp = TempDir::new().unwrap();
458        let temp_dir = temp.path().to_path_buf();
459        fs::create_dir_all(get_lit_dir(&temp_dir).join("refs/heads")).unwrap();
460
461        let config = EncryptionConfig {
462            enabled: true,
463            key_file: temp_dir
464                .join("encryption.key")
465                .to_string_lossy()
466                .to_string(),
467            ..Default::default()
468        };
469
470        let mut enc_manager = EncryptionManager::new(config);
471        enc_manager.initialize("test-passphrase-refs").unwrap();
472        let encryption = Arc::new(Mutex::new(enc_manager));
473
474        let test_hash = "abc123def456";
475        let ref_name = "heads/test-branch";
476
477        // Write encrypted ref
478        write_ref_encrypted(&temp_dir, ref_name, test_hash, &encryption).unwrap();
479
480        // Read encrypted ref
481        let read_hash = read_ref_encrypted(&temp_dir, ref_name, &encryption).unwrap();
482
483        assert_eq!(read_hash, test_hash);
484    }
485
486    #[test]
487    fn test_encrypted_head_operations() {
488        let temp = TempDir::new().unwrap();
489        let temp_dir = temp.path().to_path_buf();
490        fs::create_dir_all(get_lit_dir(&temp_dir).join("refs/heads")).unwrap();
491
492        let config = EncryptionConfig {
493            enabled: true,
494            key_file: temp_dir
495                .join("encryption.key")
496                .to_string_lossy()
497                .to_string(),
498            ..Default::default()
499        };
500
501        let mut enc_manager = EncryptionManager::new(config);
502        enc_manager.initialize("test-passphrase-head").unwrap();
503        let encryption = Arc::new(Mutex::new(enc_manager));
504
505        let branch_name = "main";
506        let commit_hash = "deadbeef123456";
507
508        // Write branch ref
509        write_ref_encrypted(&temp_dir, "heads/main", commit_hash, &encryption).unwrap();
510
511        // Update HEAD to point to branch
512        update_head_encrypted(&temp_dir, branch_name, &encryption).unwrap();
513
514        // Get current branch
515        let current = get_current_branch_encrypted(&temp_dir, &encryption).unwrap();
516        assert_eq!(current, branch_name);
517
518        // Read HEAD (should resolve to commit)
519        let head_commit = read_head_encrypted(&temp_dir, &encryption).unwrap();
520        assert_eq!(head_commit, commit_hash);
521    }
522
523    #[test]
524    fn test_encrypted_detached_head() {
525        let temp = TempDir::new().unwrap();
526        let temp_dir = temp.path().to_path_buf();
527        fs::create_dir_all(get_lit_dir(&temp_dir)).unwrap();
528
529        let config = EncryptionConfig {
530            enabled: true,
531            key_file: temp_dir
532                .join("encryption.key")
533                .to_string_lossy()
534                .to_string(),
535            ..Default::default()
536        };
537
538        let mut enc_manager = EncryptionManager::new(config);
539        enc_manager.initialize("test-passphrase-detached").unwrap();
540        let encryption = Arc::new(Mutex::new(enc_manager));
541
542        let commit_hash = "cafebabe987654";
543
544        // Set HEAD to detached state
545        set_head_detached_encrypted(&temp_dir, commit_hash, &encryption).unwrap();
546
547        // Read HEAD
548        let head = read_head_encrypted(&temp_dir, &encryption).unwrap();
549        assert_eq!(head, commit_hash);
550
551        // Getting branch should fail (detached)
552        assert!(get_current_branch_encrypted(&temp_dir, &encryption).is_err());
553    }
554
555    #[test]
556    fn test_encrypted_ref_tamper_detection() {
557        let temp = TempDir::new().unwrap();
558        let temp_dir = temp.path().to_path_buf();
559        fs::create_dir_all(get_lit_dir(&temp_dir).join("refs/heads")).unwrap();
560
561        let config = EncryptionConfig {
562            enabled: true,
563            key_file: temp_dir
564                .join("encryption.key")
565                .to_string_lossy()
566                .to_string(),
567            ..Default::default()
568        };
569
570        let mut enc_manager = EncryptionManager::new(config);
571        enc_manager.initialize("test-passphrase-tamper").unwrap();
572        let encryption = Arc::new(Mutex::new(enc_manager));
573
574        let test_hash = "original123";
575        let ref_name = "heads/tamper-test";
576
577        // Write encrypted ref
578        write_ref_encrypted(&temp_dir, ref_name, test_hash, &encryption).unwrap();
579
580        // Tamper with the encrypted file
581        let ref_path = get_lit_dir(&temp_dir).join("refs").join(ref_name);
582        let mut data = fs::read(&ref_path).unwrap();
583        let len = data.len();
584        data[len - 1] ^= 0x01; // Flip a bit
585        fs::write(&ref_path, data).unwrap();
586
587        // Reading should fail due to authentication tag mismatch
588        assert!(read_ref_encrypted(&temp_dir, ref_name, &encryption).is_err());
589    }
590}