Skip to main content

opensession_core/
object_store.rs

1use crate::source_uri::{SourceSpec, SourceUri, SourceUriError};
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct StoredObject {
7    pub uri: SourceUri,
8    pub sha256: String,
9    pub path: PathBuf,
10    pub bytes: usize,
11}
12
13#[derive(Debug, thiserror::Error)]
14pub enum ObjectStoreError {
15    #[error("could not determine home directory")]
16    HomeUnavailable,
17    #[error("invalid hash: {0}")]
18    InvalidHash(String),
19    #[error("object not found: {0}")]
20    NotFound(String),
21    #[error("uri error: {0}")]
22    Uri(#[from] SourceUriError),
23    #[error("io error: {0}")]
24    Io(#[from] std::io::Error),
25}
26
27pub fn sha256_hex(bytes: &[u8]) -> String {
28    let mut hasher = Sha256::new();
29    hasher.update(bytes);
30    let digest = hasher.finalize();
31    let mut out = String::with_capacity(digest.len() * 2);
32    for byte in digest {
33        out.push_str(&format!("{byte:02x}"));
34    }
35    out
36}
37
38pub fn store_local_object(bytes: &[u8], cwd: &Path) -> Result<StoredObject, ObjectStoreError> {
39    let sha256 = sha256_hex(bytes);
40    validate_hash(&sha256)?;
41    let root = default_store_root(cwd)?;
42    let path = object_path(&root, &sha256)?;
43    if let Some(parent) = path.parent() {
44        std::fs::create_dir_all(parent)?;
45    }
46    if !path.exists() {
47        std::fs::write(&path, bytes)?;
48    }
49    Ok(StoredObject {
50        uri: SourceUri::Src(SourceSpec::Local {
51            sha256: sha256.clone(),
52        }),
53        sha256,
54        path,
55        bytes: bytes.len(),
56    })
57}
58
59pub fn read_local_object(
60    hash: &str,
61    cwd: &Path,
62) -> Result<(SourceUri, PathBuf, Vec<u8>), ObjectStoreError> {
63    validate_hash(hash)?;
64    for root in candidate_roots(cwd)? {
65        let path = object_path(&root, hash)?;
66        if path.exists() {
67            let bytes = std::fs::read(&path)?;
68            return Ok((
69                SourceUri::Src(SourceSpec::Local {
70                    sha256: hash.to_string(),
71                }),
72                path,
73                bytes,
74            ));
75        }
76    }
77    Err(ObjectStoreError::NotFound(hash.to_string()))
78}
79
80pub fn read_local_object_from_uri(
81    uri: &SourceUri,
82    cwd: &Path,
83) -> Result<(PathBuf, Vec<u8>), ObjectStoreError> {
84    let hash = uri.as_local_hash().ok_or_else(|| {
85        ObjectStoreError::NotFound("uri is not a local source object".to_string())
86    })?;
87    let (_uri, path, bytes) = read_local_object(hash, cwd)?;
88    Ok((path, bytes))
89}
90
91pub fn default_store_root(cwd: &Path) -> Result<PathBuf, ObjectStoreError> {
92    if let Some(repo_root) = find_repo_root(cwd) {
93        return Ok(repo_root.join(".opensession").join("objects"));
94    }
95    global_store_root()
96}
97
98pub fn global_store_root() -> Result<PathBuf, ObjectStoreError> {
99    let home = std::env::var("HOME")
100        .or_else(|_| std::env::var("USERPROFILE"))
101        .map(PathBuf::from)
102        .map_err(|_| ObjectStoreError::HomeUnavailable)?;
103    Ok(home
104        .join(".local")
105        .join("share")
106        .join("opensession")
107        .join("objects"))
108}
109
110pub fn object_path(root: &Path, hash: &str) -> Result<PathBuf, ObjectStoreError> {
111    validate_hash(hash)?;
112    Ok(root
113        .join("sha256")
114        .join(&hash[0..2])
115        .join(&hash[2..4])
116        .join(format!("{hash}.jsonl")))
117}
118
119pub fn candidate_roots(cwd: &Path) -> Result<Vec<PathBuf>, ObjectStoreError> {
120    let mut roots = Vec::new();
121    if let Some(repo_root) = find_repo_root(cwd) {
122        roots.push(repo_root.join(".opensession").join("objects"));
123    }
124    roots.push(global_store_root()?);
125    roots.dedup();
126    Ok(roots)
127}
128
129pub fn find_repo_root(from: &Path) -> Option<PathBuf> {
130    let mut current = from.to_path_buf();
131    if current.is_file() {
132        current.pop();
133    }
134    loop {
135        if current.join(".git").exists() {
136            return Some(current);
137        }
138        if !current.pop() {
139            return None;
140        }
141    }
142}
143
144fn validate_hash(hash: &str) -> Result<(), ObjectStoreError> {
145    let is_valid = hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
146    if is_valid {
147        Ok(())
148    } else {
149        Err(ObjectStoreError::InvalidHash(hash.to_string()))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{find_repo_root, object_path, read_local_object, sha256_hex, store_local_object};
156    use tempfile::tempdir;
157
158    #[test]
159    fn sha256_is_stable() {
160        assert_eq!(
161            sha256_hex(b"opensession"),
162            "f9a2fe35d5e0700b552c63f8dfeb0b0853c5ab051d980b102f15254486c3c2ee".to_string()
163        );
164    }
165
166    #[test]
167    fn object_path_layout_matches_spec() {
168        let hash = "a".repeat(64);
169        let path = object_path(std::path::Path::new("/tmp/objects"), &hash).expect("path");
170        assert_eq!(
171            path,
172            std::path::PathBuf::from(format!("/tmp/objects/sha256/aa/aa/{hash}.jsonl"))
173        );
174    }
175
176    #[test]
177    fn store_and_read_repo_scoped_object() {
178        let tmp = tempdir().expect("tempdir");
179        std::fs::create_dir_all(tmp.path().join(".git")).expect("create .git");
180        let nested = tmp.path().join("a/b/c");
181        std::fs::create_dir_all(&nested).expect("create nested");
182
183        let stored =
184            store_local_object(b"{\"type\":\"header\"}\n", &nested).expect("store local object");
185        let (uri, path, bytes) =
186            read_local_object(&stored.sha256, &nested).expect("read local object");
187        assert_eq!(uri.to_string(), stored.uri.to_string());
188        assert_eq!(path, stored.path);
189        assert_eq!(bytes, b"{\"type\":\"header\"}\n");
190    }
191
192    #[test]
193    fn finds_repo_root_from_nested_path() {
194        let tmp = tempdir().expect("tempdir");
195        std::fs::create_dir_all(tmp.path().join(".git")).expect("create .git");
196        let nested = tmp.path().join("x/y/z");
197        std::fs::create_dir_all(&nested).expect("create nested");
198        assert_eq!(find_repo_root(&nested), Some(tmp.path().to_path_buf()));
199    }
200}