1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4
5fn validate_sha256(sha256: &str) -> Result<(), String> {
6 if sha256.len() != 64
7 || !sha256
8 .bytes()
9 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
10 {
11 return Err(format!(
12 "invalid sha256: expected 64 lowercase hex characters, got '{}'",
13 crate::util::truncate_bytes(sha256, 16)
14 ));
15 }
16 Ok(())
17}
18
19pub fn short_hash(s: &str) -> String {
23 crate::util::truncate_bytes(s, 12)
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Receipt {
29 pub url: String,
30 pub final_url: Option<String>,
31 pub redirects: Vec<String>,
32 pub sha256: String,
33 pub size: u64,
34 pub domains_referenced: Vec<String>,
35 pub paths_referenced: Vec<String>,
36 pub analysis_method: String,
37 pub privilege: String,
38 pub timestamp: String,
39 pub cwd: Option<String>,
40 pub git_repo: Option<String>,
41 pub git_branch: Option<String>,
42}
43
44impl Receipt {
45 pub fn save(&self) -> Result<PathBuf, String> {
47 validate_sha256(&self.sha256)?;
48 let dir = receipts_dir().ok_or("cannot determine receipts directory")?;
49 fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?;
50
51 let path = dir.join(format!("{}.json", self.sha256));
52
53 let json = serde_json::to_string_pretty(self).map_err(|e| format!("serialize: {e}"))?;
54
55 {
56 use std::io::Write;
57 use tempfile::NamedTempFile;
58
59 let mut tmp = NamedTempFile::new_in(&dir).map_err(|e| format!("tempfile: {e}"))?;
60 #[cfg(unix)]
61 {
62 use std::os::unix::fs::PermissionsExt;
63 tmp.as_file()
64 .set_permissions(std::fs::Permissions::from_mode(0o600))
65 .map_err(|e| format!("permissions: {e}"))?;
66 }
67 tmp.write_all(json.as_bytes())
68 .map_err(|e| format!("write: {e}"))?;
69 tmp.persist(&path).map_err(|e| format!("persist: {e}"))?;
70 }
71
72 Ok(path)
73 }
74
75 pub fn load(sha256: &str) -> Result<Self, String> {
77 validate_sha256(sha256)?;
78 let dir = receipts_dir().ok_or("cannot determine receipts directory")?;
79 let path = dir.join(format!("{sha256}.json"));
80 let content = fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
81 serde_json::from_str(&content).map_err(|e| format!("parse: {e}"))
82 }
83
84 pub fn list() -> Result<Vec<Self>, String> {
86 let dir = receipts_dir().ok_or("cannot determine receipts directory")?;
87 if !dir.exists() {
88 return Ok(Vec::new());
89 }
90
91 let mut receipts = Vec::new();
92 let entries = fs::read_dir(&dir).map_err(|e| format!("read dir: {e}"))?;
93 for entry in entries {
94 let entry = entry.map_err(|e| format!("entry: {e}"))?;
95 let path = entry.path();
96 if path.extension().is_some_and(|e| e == "json")
97 && !path
98 .file_name()
99 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
100 {
101 if let Ok(content) = fs::read_to_string(&path) {
102 if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
103 receipts.push(receipt);
104 }
105 }
106 }
107 }
108
109 receipts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
110 Ok(receipts)
111 }
112
113 pub fn verify(&self) -> Result<bool, String> {
115 validate_sha256(&self.sha256)?;
116 let cache_dir = cache_dir().ok_or("cannot determine cache directory")?;
117 let cached = cache_dir.join(&self.sha256);
118 if !cached.exists() {
119 return Ok(false);
120 }
121
122 let content = fs::read(&cached).map_err(|e| format!("read: {e}"))?;
123 let hash = sha2_hex(&content);
124 Ok(hash == self.sha256)
125 }
126}
127
128fn receipts_dir() -> Option<PathBuf> {
129 crate::policy::data_dir().map(|d| d.join("receipts"))
130}
131
132fn cache_dir() -> Option<PathBuf> {
133 crate::policy::data_dir().map(|d| d.join("cache"))
134}
135
136fn sha2_hex(data: &[u8]) -> String {
137 use sha2::{Digest, Sha256};
138 let mut hasher = Sha256::new();
139 hasher.update(data);
140 format!("{:x}", hasher.finalize())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn test_validate_sha256_valid() {
149 let hash = "a".repeat(64);
150 assert!(validate_sha256(&hash).is_ok());
151 }
152
153 #[test]
154 fn test_validate_sha256_too_short() {
155 assert!(validate_sha256("abc").is_err());
156 }
157
158 #[test]
159 fn test_validate_sha256_path_traversal() {
160 assert!(validate_sha256("../../etc/passwd").is_err());
161 }
162
163 #[test]
164 fn test_validate_sha256_uppercase_rejected() {
165 let hash = "A".repeat(64);
166 assert!(validate_sha256(&hash).is_err());
167 }
168
169 #[test]
170 fn test_short_hash_short_input() {
171 assert_eq!(short_hash("abc"), "abc");
172 }
173
174 #[test]
175 fn test_short_hash_normal() {
176 let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
177 assert_eq!(short_hash(hash), "abcdef012345");
178 }
179
180 #[test]
181 fn test_short_hash_non_ascii() {
182 let s = "日本語テスト";
184 let result = short_hash(s);
185 assert!(!result.is_empty());
186 assert!(result.len() <= 12);
187 }
188
189 #[test]
190 fn test_receipt_save_no_predictable_tmp() {
191 let dir = tempfile::tempdir().unwrap();
193 let receipts_sub = dir.path().join("receipts");
194 std::fs::create_dir_all(&receipts_sub).unwrap();
195
196 let sha = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
197
198 let path = receipts_sub.join(format!("{sha}.json"));
199 let json = r#"{"test": true}"#;
200 {
201 use std::io::Write;
202 use tempfile::NamedTempFile;
203
204 let mut tmp = NamedTempFile::new_in(&receipts_sub).unwrap();
205 tmp.write_all(json.as_bytes()).unwrap();
206 tmp.persist(&path).unwrap();
207 }
208
209 let old_tmp = receipts_sub.join(format!(".{sha}.json.tmp"));
211 assert!(
212 !old_tmp.exists(),
213 "predictable .{{sha}}.json.tmp should not exist after NamedTempFile save"
214 );
215 assert!(path.exists(), "receipt file should exist after persist");
217 }
218
219 #[cfg(unix)]
220 #[test]
221 fn test_receipt_save_permissions_0600() {
222 use std::os::unix::fs::PermissionsExt;
223
224 let dir = tempfile::tempdir().unwrap();
225 let receipts_dir = dir.path().join("receipts");
226 std::fs::create_dir_all(&receipts_dir).unwrap();
227
228 let sha = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
229
230 let path = receipts_dir.join(format!("{sha}.json"));
232 let json = r#"{"test": true}"#;
233 {
234 use std::io::Write;
235 use std::os::unix::fs::OpenOptionsExt;
236 let mut opts = std::fs::OpenOptions::new();
237 opts.write(true).create(true).truncate(true);
238 opts.mode(0o600);
239 let mut f = opts.open(&path).unwrap();
240 f.write_all(json.as_bytes()).unwrap();
241 }
242
243 let meta = std::fs::metadata(&path).unwrap();
244 assert_eq!(
245 meta.permissions().mode() & 0o777,
246 0o600,
247 "receipt file should be 0600"
248 );
249 }
250}