Skip to main content

sui_cache/
push.rs

1//! Push pipeline — build output to NAR to sign to upload.
2//!
3//! Takes a store path, dumps it as NAR, compresses with xz,
4//! builds narinfo metadata, signs it, and uploads both to the
5//! configured storage backend.
6
7use std::io::Write;
8use std::path::Path;
9
10use sha2::{Digest, Sha256};
11use sui_compat::nar::NarWriter;
12use sui_compat::narinfo::NarInfo;
13
14use crate::signing::CacheSigner;
15use crate::storage::StorageBackend;
16use crate::CacheError;
17
18/// Result of pushing a single store path.
19#[derive(Debug, Clone)]
20pub struct PushResult {
21    /// The store path hash used as the narinfo key.
22    pub hash: String,
23    /// Size of the compressed NAR blob uploaded.
24    pub compressed_size: u64,
25    /// Size of the uncompressed NAR.
26    pub nar_size: u64,
27}
28
29/// Push a store path to the binary cache.
30///
31/// 1. Dump the path as NAR
32/// 2. Hash the uncompressed NAR (sha256)
33/// 3. Compress with xz
34/// 4. Hash the compressed NAR (sha256)
35/// 5. Build narinfo metadata
36/// 6. Sign the narinfo
37/// 7. Upload NAR blob and narinfo
38///
39/// The `store_path` should be an absolute path like `/nix/store/abc-hello-1.0`.
40/// The `hash` is the 32-character store path hash (the `abc` part).
41///
42/// `references` are the runtime dependency store path basenames.
43pub async fn push_path(
44    storage: &dyn StorageBackend,
45    signer: &CacheSigner,
46    store_path: &str,
47    hash: &str,
48    references: &[String],
49    deriver: Option<&str>,
50) -> Result<PushResult, CacheError> {
51    let path = Path::new(store_path);
52    if !path.exists() {
53        return Err(CacheError::PathNotFound(store_path.to_string()));
54    }
55
56    // 1. Dump to NAR.
57    let nar_data = dump_path_to_nar(path)?;
58
59    // 2. Hash uncompressed NAR.
60    let nar_hash = sha256_hex(&nar_data);
61    let nar_size = nar_data.len() as u64;
62
63    // 3. Compress with xz.
64    let compressed = compress_xz(&nar_data)?;
65    let compressed_size = compressed.len() as u64;
66
67    // 4. Hash compressed NAR.
68    let file_hash = sha256_hex(&compressed);
69
70    // 5. Build narinfo.
71    let nar_url = format!("nar/{hash}.nar.xz");
72    let narinfo = NarInfo {
73        store_path: store_path.to_string(),
74        url: nar_url.clone(),
75        compression: "xz".to_string(),
76        file_hash: format!("sha256:{file_hash}"),
77        file_size: compressed_size,
78        nar_hash: format!("sha256:{nar_hash}"),
79        nar_size,
80        references: references.to_vec(),
81        deriver: deriver.map(String::from),
82        signatures: vec![],
83        ca: None,
84    };
85
86    // 6. Sign.
87    let sig = signer.sign_narinfo(&narinfo);
88    let narinfo = NarInfo {
89        signatures: vec![sig],
90        ..narinfo
91    };
92
93    // 7. Upload.
94    storage.put_nar(&nar_url, &compressed).await?;
95    storage.put_narinfo(hash, &narinfo.serialize()).await?;
96
97    Ok(PushResult {
98        hash: hash.to_string(),
99        compressed_size,
100        nar_size,
101    })
102}
103
104/// Dump a filesystem path to NAR format in memory.
105fn dump_path_to_nar(path: &Path) -> Result<Vec<u8>, CacheError> {
106    let mut buf = Vec::new();
107    NarWriter::write_path(&mut buf, path).map_err(|e| {
108        CacheError::Io(std::io::Error::new(
109            std::io::ErrorKind::Other,
110            format!("NAR dump failed: {e}"),
111        ))
112    })?;
113    Ok(buf)
114}
115
116/// Compress data with xz (level 6).
117fn compress_xz(data: &[u8]) -> Result<Vec<u8>, CacheError> {
118    let mut compressed = Vec::new();
119    let mut encoder = xz2::write::XzEncoder::new(&mut compressed, 6);
120    encoder.write_all(data).map_err(CacheError::Io)?;
121    encoder.finish().map_err(CacheError::Io)?;
122    Ok(compressed)
123}
124
125/// Compute SHA-256 hash and return lowercase hex.
126fn sha256_hex(data: &[u8]) -> String {
127    let digest = Sha256::digest(data);
128    let mut s = String::with_capacity(64);
129    for b in digest.as_slice() {
130        use std::fmt::Write;
131        let _ = write!(s, "{b:02x}");
132    }
133    s
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::signing::CacheSigner;
140    use crate::storage::local::LocalStorage;
141
142    #[tokio::test]
143    async fn push_single_file() {
144        let cache_dir = tempfile::tempdir().unwrap();
145        let storage = LocalStorage::new(cache_dir.path());
146        let signer = CacheSigner::generate("test-cache".to_string());
147
148        // Create a store path to push.
149        let store_dir = tempfile::tempdir().unwrap();
150        let fake_store = store_dir.path().join("nix/store/abc-hello-1.0");
151        std::fs::create_dir_all(&fake_store).unwrap();
152        std::fs::write(fake_store.join("hello.txt"), b"Hello world!").unwrap();
153
154        let result = push_path(
155            &storage,
156            &signer,
157            fake_store.to_str().unwrap(),
158            "abc",
159            &[],
160            None,
161        )
162        .await
163        .unwrap();
164
165        assert_eq!(result.hash, "abc");
166        assert!(result.nar_size > 0);
167        assert!(result.compressed_size > 0);
168
169        // Verify narinfo was uploaded.
170        let narinfo = storage.get_narinfo("abc").await.unwrap().unwrap();
171        let parsed = NarInfo::parse(&narinfo).unwrap();
172        assert_eq!(parsed.compression, "xz");
173        assert_eq!(parsed.signatures.len(), 1);
174        assert!(parsed.signatures[0].starts_with("test-cache:"));
175
176        // Verify NAR blob was uploaded.
177        let nar = storage.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
178        assert!(!nar.is_empty());
179    }
180
181    #[tokio::test]
182    async fn push_nonexistent_path_errors() {
183        let dir = tempfile::tempdir().unwrap();
184        let storage = LocalStorage::new(dir.path());
185        let signer = CacheSigner::generate("k".to_string());
186
187        let result = push_path(
188            &storage,
189            &signer,
190            "/nix/store/does-not-exist-12345",
191            "nope",
192            &[],
193            None,
194        )
195        .await;
196
197        assert!(result.is_err());
198        assert!(matches!(result, Err(CacheError::PathNotFound(_))));
199    }
200
201    #[tokio::test]
202    async fn push_with_references() {
203        let cache_dir = tempfile::tempdir().unwrap();
204        let storage = LocalStorage::new(cache_dir.path());
205        let signer = CacheSigner::generate("k".to_string());
206
207        let store_dir = tempfile::tempdir().unwrap();
208        let path = store_dir.path().join("pkg");
209        std::fs::create_dir_all(&path).unwrap();
210        std::fs::write(path.join("file"), b"data").unwrap();
211
212        let refs = vec!["dep1-glibc".to_string(), "dep2-gcc".to_string()];
213        let result = push_path(
214            &storage,
215            &signer,
216            path.to_str().unwrap(),
217            "xyz",
218            &refs,
219            Some("builder.drv"),
220        )
221        .await
222        .unwrap();
223
224        assert_eq!(result.hash, "xyz");
225
226        let narinfo = storage.get_narinfo("xyz").await.unwrap().unwrap();
227        let parsed = NarInfo::parse(&narinfo).unwrap();
228        assert_eq!(parsed.references, refs);
229        assert_eq!(parsed.deriver, Some("builder.drv".to_string()));
230    }
231
232    #[tokio::test]
233    async fn pushed_narinfo_is_valid_and_verifiable() {
234        let cache_dir = tempfile::tempdir().unwrap();
235        let storage = LocalStorage::new(cache_dir.path());
236        let signer = CacheSigner::generate("verify-key".to_string());
237        let pk_str = signer.public_key_string();
238
239        let store_dir = tempfile::tempdir().unwrap();
240        let path = store_dir.path().join("test-pkg");
241        std::fs::create_dir_all(&path).unwrap();
242        std::fs::write(path.join("data"), b"test content").unwrap();
243
244        push_path(
245            &storage,
246            &signer,
247            path.to_str().unwrap(),
248            "ttt",
249            &[],
250            None,
251        )
252        .await
253        .unwrap();
254
255        let narinfo_text = storage.get_narinfo("ttt").await.unwrap().unwrap();
256        let parsed = NarInfo::parse(&narinfo_text).unwrap();
257
258        // Verify the signature.
259        let valid = crate::signing::verify_narinfo_signature(
260            &parsed,
261            &parsed.signatures[0],
262            &pk_str,
263        )
264        .unwrap();
265        assert!(valid);
266    }
267
268    #[test]
269    fn sha256_hex_produces_correct_output() {
270        // SHA-256 of empty string is well-known.
271        let hash = sha256_hex(b"");
272        assert_eq!(
273            hash,
274            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
275        );
276    }
277
278    #[test]
279    fn compress_xz_produces_valid_output() {
280        let data = b"hello world, this is test data for xz compression";
281        let compressed = compress_xz(data).unwrap();
282        // Decompress to verify.
283        use std::io::Read;
284        let mut decoder = xz2::read::XzDecoder::new(compressed.as_slice());
285        let mut decompressed = Vec::new();
286        decoder.read_to_end(&mut decompressed).unwrap();
287        assert_eq!(decompressed, data);
288    }
289}