Skip to main content

tirith_core/
receipt.rs

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
19/// Safe short prefix of a hash for display. Uses the existing UTF-8-safe
20/// `truncate_bytes` utility to handle any string safely, including
21/// corrupted receipt JSON with non-ASCII sha256 values.
22pub fn short_hash(s: &str) -> String {
23    crate::util::truncate_bytes(s, 12)
24}
25
26/// A receipt for a script that was downloaded and analyzed.
27#[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    /// Save receipt atomically (temp file + rename).
46    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    /// Load a receipt by SHA256.
76    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    /// List all receipts.
85    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    /// Verify a receipt: check if the file at the cached path still matches sha256.
114    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        // Multi-byte UTF-8: each char is 3 bytes, so 12 bytes = 4 chars
183        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        // Verify NamedTempFile is used: no .{sha}.json.tmp should remain after save.
192        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        // The old predictable tmp file should NOT exist
210        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        // The final file should exist
216        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        // Write a receipt file with 0600 permissions using the same pattern as save()
231        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}