opensession_core/
object_store.rs1use 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}