Skip to main content

rootle_github/
cache.rs

1//! Content-addressable disk cache for the GitHub provider, under
2//! ~/.cache/rootle/providers/github (plans/0005: each provider owns its
3//! cache subtree; the TUI-level edit/ scratch stays at ~/.cache/rootle).
4//! Trees are immutable (sha-keyed) — never invalidated, only evicted.
5//! Ref mappings (owner/repo/branch → tree_sha + etag) are mutable and
6//! revalidated with If-None-Match on every open.
7
8use crate::types::TreeResponse;
9use serde::{Deserialize, Serialize};
10use std::io;
11use std::os::unix::fs::MetadataExt;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RefCache {
16    pub tree_sha: String,
17    pub etag: Option<String>,
18}
19
20/// The provider's cache subtree. Other providers must use their own
21/// (`~/.cache/rootle/providers/<name>`) — see doc/provider-protocol.md.
22#[cfg(test)]
23pub fn root() -> Option<PathBuf> {
24    rootle_provider::paths::cache_dir().map(|d| d.join("rootle").join("providers").join("github"))
25}
26
27/// One-time move from the pre-provider layout (~/.cache/rootle/{trees,
28/// blobs,index}) into this provider's subtree. Best-effort: partial
29/// moves just mean re-fetches.
30fn migrate_from_legacy(base: &Path) {
31    let mine = base.join("providers").join("github");
32    for dir in ["trees", "blobs", "index"] {
33        let old = base.join(dir);
34        let new = mine.join(dir);
35        if old.exists() && !new.exists() {
36            let _ = std::fs::create_dir_all(&mine);
37            let _ = std::fs::rename(&old, &new);
38        }
39    }
40}
41
42/// Resolve the cache root, migrating the legacy layout once.
43fn root_or_migrate() -> Option<PathBuf> {
44    let base = rootle_provider::paths::cache_dir().map(|d| d.join("rootle"))?;
45    migrate_from_legacy(&base);
46    Some(base.join("providers").join("github"))
47}
48
49/// Path components (owner, repo, branch, sha) come from API responses
50/// and are not trusted to be well-formed: percent-encode everything
51/// outside [A-Za-z0-9_-] so a `feature/foo` branch (a legitimate
52/// name) stays one path segment and separators / `..` can never
53/// become path structure. Dots encode too — a literal `..` must not
54/// survive as a component.
55fn encode_component(s: &str) -> String {
56    let mut out = String::with_capacity(s.len());
57    for b in s.bytes() {
58        match b {
59            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' => out.push(b as char),
60            _ => out.push_str(&format!("%{b:02X}")),
61        }
62    }
63    out
64}
65
66/// Inverse of `encode_component`; invalid sequences pass through —
67/// they can only come from files this module wrote.
68fn decode_component(s: &str) -> String {
69    let b = s.as_bytes();
70    let mut out = Vec::with_capacity(b.len());
71    let mut i = 0;
72    while i < b.len() {
73        if b[i] == b'%' && i + 2 < b.len() {
74            let hex = |c: u8| {
75                (c as char)
76                    .is_ascii_hexdigit()
77                    .then(|| (c as char).to_digit(16).unwrap() as u8)
78            };
79            if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
80                out.push((h << 4) | l);
81                i += 3;
82                continue;
83            }
84        }
85        out.push(b[i]);
86        i += 1;
87    }
88    String::from_utf8_lossy(&out).into_owned()
89}
90
91fn ref_path(root: &std::path::Path, owner: &str, repo: &str, branch: &str) -> PathBuf {
92    root.join("index/refs")
93        .join(encode_component(owner))
94        .join(encode_component(repo))
95        .join(encode_component(branch))
96}
97
98fn tree_path(root: &std::path::Path, sha: &str) -> PathBuf {
99    root.join("trees")
100        .join(format!("{}.json", encode_component(sha)))
101}
102
103/// Blobs fan out by the sha's first 2 chars: blobs/<ab>/<rest>. The
104/// encoded sha is pure ASCII, so the byte slices can't split a char.
105fn blob_path(root: &std::path::Path, sha: &str) -> PathBuf {
106    let sha = encode_component(sha);
107    let split = 2.min(sha.len());
108    root.join("blobs").join(&sha[..split]).join(&sha[split..])
109}
110
111pub fn read_blob(sha: &str) -> Option<Vec<u8>> {
112    let root = root_or_migrate()?;
113    let path = blob_path(&root, sha);
114    let bytes = std::fs::read(&path).ok();
115    let hit = &bytes;
116    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
117        serde_json::json!({
118            "op": "blob_read",
119            "sha": sha,
120            "hit": hit.is_some(),
121            "bytes": hit.as_ref().map_or(0, |b| b.len()),
122        })
123    });
124    let bytes = bytes?;
125    touch(&path); // mtime = last-used, drives LRU eviction
126    Some(bytes)
127}
128
129pub fn write_blob(sha: &str, bytes: &[u8]) -> io::Result<()> {
130    let Some(root) = root_or_migrate() else {
131        return Ok(());
132    };
133    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
134        serde_json::json!({
135            "op": "blob_write",
136            "sha": sha,
137            "bytes": bytes.len(),
138        })
139    });
140    atomic_write(&blob_path(&root, sha), bytes)
141}
142
143pub fn cached_branch(owner: &str, repo: &str) -> Option<String> {
144    let branch = root_or_migrate().and_then(|root| {
145        let dir = root
146            .join("index/refs")
147            .join(encode_component(owner))
148            .join(encode_component(repo));
149        // Skip incomplete atomic writes; decode the provider's opaque ref name.
150        let found = std::fs::read_dir(dir)
151            .ok()?
152            .filter_map(|entry| entry.ok())
153            .find(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp"));
154        found.and_then(|entry| {
155            entry
156                .file_name()
157                .into_string()
158                .ok()
159                .map(|name| decode_component(&name))
160        })
161    });
162    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
163        serde_json::json!({
164            "op": "cached_branch",
165            "owner": owner,
166            "repo": repo,
167            "hit": branch.is_some(),
168        })
169    });
170    branch
171}
172
173pub fn read_ref(owner: &str, repo: &str, branch: &str) -> Option<RefCache> {
174    let text = root_or_migrate()
175        .and_then(|root| std::fs::read_to_string(ref_path(&root, owner, repo, branch)).ok());
176    let entry = text
177        .as_deref()
178        .and_then(|text| serde_json::from_str(text).ok());
179    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
180        serde_json::json!({
181            "op": "ref_read",
182            "owner": owner,
183            "repo": repo,
184            "branch": branch,
185            "hit": entry.is_some(),
186            "miss_reason": if entry.is_some() { None } else if text.is_some() { Some("corrupt") } else { Some("unavailable") },
187        })
188    });
189    entry
190}
191
192pub fn write_ref(owner: &str, repo: &str, branch: &str, entry: &RefCache) -> io::Result<()> {
193    let Some(root) = root_or_migrate() else {
194        return Ok(());
195    };
196    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
197        serde_json::json!({
198            "op": "ref_write",
199            "owner": owner,
200            "repo": repo,
201            "branch": branch,
202        })
203    });
204    atomic_write(
205        &ref_path(&root, owner, repo, branch),
206        serde_json::to_string(entry)?.as_bytes(),
207    )
208}
209
210pub fn read_tree(sha: &str) -> Option<TreeResponse> {
211    let Some(root) = root_or_migrate() else {
212        rootle_trace::record_with(
213            rootle_trace::EventKind::Cache,
214            || serde_json::json!({"op":"tree_read","sha":sha,"hit":false,"miss_reason":"no_cache_directory"}),
215        );
216        return None;
217    };
218    let path = tree_path(&root, sha);
219    let (tree, miss_reason) = match std::fs::read_to_string(&path) {
220        Ok(text) => match serde_json::from_str::<TreeResponse>(&text) {
221            Ok(tree) => (Some(tree), None),
222            // A present-but-corrupt body is the documented 304 trap:
223            // the caller refetches unconditionally and heals both the
224            // tree and its ref (trees.rs).
225            Err(_) => (None, Some("corrupt")),
226        },
227        Err(e) => (
228            None,
229            Some(if e.kind() == io::ErrorKind::NotFound {
230                "absent"
231            } else {
232                "unreadable"
233            }),
234        ),
235    };
236    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
237        serde_json::json!({
238            "op": "tree_read",
239            "sha": sha,
240            "hit": tree.is_some(),
241            "miss_reason": miss_reason,
242        })
243    });
244    tree
245}
246
247pub fn write_tree(tree: &TreeResponse) -> io::Result<()> {
248    let Some(root) = root_or_migrate() else {
249        return Ok(());
250    };
251    let text = serde_json::to_string(tree)?;
252    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
253        serde_json::json!({
254            "op": "tree_write",
255            "sha": tree.sha,
256            "bytes": text.len(),
257        })
258    });
259    atomic_write(&tree_path(&root, &tree.sha), text.as_bytes())
260}
261
262/// tmp + rename — a kill mid-write never yields a corrupt entry.
263fn atomic_write(path: &PathBuf, bytes: &[u8]) -> io::Result<()> {
264    if let Some(parent) = path.parent() {
265        std::fs::create_dir_all(parent)?;
266    }
267    let tmp = path.with_extension("tmp");
268    std::fs::write(&tmp, bytes)?;
269    std::fs::rename(&tmp, path)?;
270    Ok(())
271}
272
273// ---------------------------------------------------------------------------
274// Hardening (PLAN.md §8 / milestone 7)
275//
276// LRU: file mtime is last-used — `read_blob` touches it, so eviction
277// needs no index file. Sweep: a tree not referenced by any cached ref,
278// and a blob not referenced by any cached tree, are orphans.
279// ---------------------------------------------------------------------------
280
281/// Startup hardening: sweep orphans, then evict least-recently-used
282/// blobs while total size exceeds `max_bytes`.
283pub fn harden(max_bytes: u64) {
284    let Some(root) = root_or_migrate() else {
285        return;
286    };
287    let (trees_removed, blobs_removed) = sweep_orphans(&root);
288    let (evicted, reclaimed) = evict_blobs(&root, max_bytes);
289    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
290        serde_json::json!({
291            "op": "sweep",
292            "trees_removed": trees_removed,
293            "blobs_removed": blobs_removed,
294        })
295    });
296    rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
297        serde_json::json!({
298            "op": "evict",
299            "evicted": evicted,
300            "reclaimed_bytes": reclaimed,
301            "budget_bytes": max_bytes,
302        })
303    });
304}
305
306/// Best-effort mtime bump so eviction sees the blob as recently used.
307fn touch(path: &PathBuf) {
308    let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
309}
310
311fn walk_files(dir: &PathBuf, out: &mut Vec<PathBuf>) {
312    let Ok(entries) = std::fs::read_dir(dir) else {
313        return;
314    };
315    for entry in entries.flatten() {
316        let path = entry.path();
317        if path.is_dir() {
318            walk_files(&path, out);
319        } else {
320            out.push(path);
321        }
322    }
323}
324
325/// Returns (trees_removed, blobs_removed).
326fn sweep_orphans(root: &Path) -> (usize, usize) {
327    let index = root.join("index").join("refs");
328    let trees_dir = root.join("trees");
329    let blobs_dir = root.join("blobs");
330
331    let mut trees_removed = 0usize;
332    let mut blobs_removed = 0usize;
333    // Tree shas referenced by at least one cached ref.
334    let mut referenced_trees = std::collections::HashSet::new();
335    let mut ref_files = Vec::new();
336    walk_files(&index, &mut ref_files);
337    for file in ref_files {
338        if let Ok(text) = std::fs::read_to_string(&file)
339            && let Ok(entry) = serde_json::from_str::<RefCache>(&text)
340        {
341            referenced_trees.insert(entry.tree_sha);
342        }
343    }
344
345    // Delete unreferenced trees; keep the referenced ones' filenames.
346    let mut live_trees = Vec::new();
347    let mut tree_files = Vec::new();
348    walk_files(&trees_dir, &mut tree_files);
349    for file in tree_files {
350        let sha = file
351            .file_stem()
352            .unwrap_or_default()
353            .to_string_lossy()
354            .into_owned();
355        if referenced_trees.contains(&sha) {
356            live_trees.push(file);
357        } else {
358            trees_removed += std::fs::remove_file(&file).is_ok() as usize;
359        }
360    }
361
362    // Blob shas referenced by a live tree.
363    let mut referenced_blobs = std::collections::HashSet::new();
364    for file in &live_trees {
365        if let Ok(text) = std::fs::read_to_string(file)
366            && let Ok(tree) = serde_json::from_str::<TreeResponse>(&text)
367        {
368            referenced_blobs.extend(
369                tree.tree
370                    .iter()
371                    .filter(|e| e.kind == "blob")
372                    .map(|e| e.sha.clone()),
373            );
374        }
375    }
376
377    // Delete unreferenced blobs (sha = <fanout>/<rest>).
378    let mut blob_files = Vec::new();
379    walk_files(&blobs_dir, &mut blob_files);
380    for file in blob_files {
381        let sha = format!(
382            "{}{}",
383            file.parent()
384                .and_then(|p| p.file_name())
385                .unwrap_or_default()
386                .to_string_lossy(),
387            file.file_name().unwrap_or_default().to_string_lossy()
388        );
389        if !referenced_blobs.contains(&sha) {
390            blobs_removed += std::fs::remove_file(&file).is_ok() as usize;
391        }
392    }
393    (trees_removed, blobs_removed)
394}
395
396/// Returns (blobs_evicted, bytes_reclaimed).
397fn evict_blobs(root: &Path, max_bytes: u64) -> (usize, u64) {
398    let blobs_dir = root.join("blobs");
399    let mut files = Vec::new();
400    walk_files(&blobs_dir, &mut files);
401
402    let mut entries: Vec<(PathBuf, u64, i64)> = files
403        .into_iter()
404        .filter_map(|f| {
405            let meta = std::fs::metadata(&f).ok()?;
406            Some((f, meta.len(), meta.mtime()))
407        })
408        .collect();
409    let mut total: u64 = entries.iter().map(|(_, size, _)| size).sum();
410    if total <= max_bytes {
411        return (0, 0);
412    }
413
414    // Oldest (least-recently-used) first.
415    entries.sort_by_key(|(_, _, mtime)| *mtime);
416    let mut evicted = 0usize;
417    let mut reclaimed = 0u64;
418    for (path, size, _) in entries {
419        if total <= max_bytes {
420            break;
421        }
422        if std::fs::remove_file(&path).is_ok() {
423            total -= size;
424            evicted += 1;
425            reclaimed += size;
426        }
427    }
428    (evicted, reclaimed)
429}
430
431#[cfg(test)]
432mod tests;