This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / lazy-store / src / nar.rs
7.3 kB 201 lines
1//! NAR unpacking and store path listings. 2 3use std::collections::BTreeMap; 4use std::fs; 5use std::io; 6use std::os::unix::fs::{MetadataExt, PermissionsExt}; 7use std::path::Path; 8 9use futures_util::TryStreamExt; 10use harmonia_file_core::{Directory, FileSystemObject, FileTree, Regular, Symlink}; 11use harmonia_file_nar::{parse_nar, restore, NarWriteError}; 12use harmonia_utils_hash::{fmt::Any, Algorithm, Context, Hash}; 13use serde::Deserialize; 14use tokio::io::{AsyncRead, AsyncReadExt}; 15use tokio_util::io::InspectReader; 16 17/// Does `actual` match a narinfo NarHash (`sha256:` in any encoding)? 18pub fn hash_matches(expected: &str, actual: &Hash) -> bool { 19 expected 20 .parse::<Any<Hash>>() 21 .is_ok_and(|e| e.into_hash() == *actual) 22} 23 24#[derive(Debug, Clone, PartialEq, Deserialize)] 25pub struct Size { 26 pub size: u64, 27} 28 29/// The metadata of a store path, as in the caches' `.ls` listings. 30pub type Tree = FileTree<Size>; 31 32/// Parse a `.ls` listing (`{"version": 1, "root": {...}}`). Empty 33/// directories may come without `entries`. 34pub fn from_listing(body: &[u8]) -> io::Result<Tree> { 35 fn fill(v: &mut serde_json::Value) { 36 if v["type"] == "directory" { 37 match v.get_mut("entries").and_then(|e| e.as_object_mut()) { 38 Some(e) => e.values_mut().for_each(fill), 39 None => v["entries"] = serde_json::json!({}), 40 } 41 } 42 } 43 let mut v: serde_json::Value = serde_json::from_slice(body).map_err(io::Error::other)?; 44 let mut root = v["root"].take(); 45 fill(&mut root); 46 serde_json::from_value(root).map_err(io::Error::other) 47} 48 49/// Listing of an unpacked store path (for paths without `.ls`). 50pub fn from_path(p: &Path) -> io::Result<Tree> { 51 let m = fs::symlink_metadata(p)?; 52 let t = m.file_type(); 53 Ok(FileTree(if t.is_dir() { 54 let mut entries = BTreeMap::new(); 55 for d in fs::read_dir(p)? { 56 let d = d?; 57 let name = d.file_name().into_string().map_err(|n| { 58 io::Error::other(format!("non-UTF-8 name {n:?} in {}", p.display())) 59 })?; 60 entries.insert(name, Box::new(from_path(&d.path())?)); 61 } 62 FileSystemObject::Directory(Directory { entries }) 63 } else if t.is_symlink() { 64 let target = fs::read_link(p)?.to_string_lossy().into_owned(); 65 FileSystemObject::Symlink(Symlink { target }) 66 } else { 67 FileSystemObject::Regular(Regular { 68 executable: m.mode() & 0o100 != 0, 69 contents: Size { size: m.size() }, 70 }) 71 })) 72} 73 74/// Unpack the NAR read from `r` to `dest` (which must not exist), read-only 75/// like in the store. Returns the NAR's sha256 and size; checking them is 76/// up to the caller. Data after the NAR counts into the size. 77pub async fn unpack(r: impl AsyncRead + Unpin + Send, dest: &Path) -> io::Result<(Hash, u64)> { 78 let mut hash = Context::new(Algorithm::SHA256); 79 let mut size = 0; 80 let mut r = InspectReader::new(r, |b: &[u8]| { 81 hash.update(b); 82 size += b.len() as u64; 83 }); 84 let events = 85 parse_nar(&mut r).map_err(|e| NarWriteError::create_file_error(dest.to_owned(), e)); 86 restore(events, dest).await.map_err(io::Error::other)?; 87 r.read(&mut [0; 1]).await?; 88 drop(r); 89 let d = dest.to_owned(); 90 tokio::task::spawn_blocking(move || read_only(&d)) 91 .await 92 .map_err(io::Error::other)??; 93 Ok((hash.finish(), size)) 94} 95 96/// Store permissions: 0444 or 0555 for files, 0555 for directories. 97fn read_only(p: &Path) -> io::Result<()> { 98 let m = fs::symlink_metadata(p)?; 99 let mode = if m.is_symlink() { 100 return Ok(()); 101 } else if m.is_dir() { 102 for d in fs::read_dir(p)? { 103 read_only(&d?.path())?; 104 } 105 0o555 106 } else if m.mode() & 0o100 != 0 { 107 0o555 108 } else { 109 0o444 110 }; 111 fs::set_permissions(p, fs::Permissions::from_mode(mode)) 112} 113 114/// Remove a (partially) unpacked tree, including read-only directories. 115pub fn remove(p: &Path) -> io::Result<()> { 116 let m = match fs::symlink_metadata(p) { 117 Ok(m) => m, 118 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), 119 Err(e) => return Err(e), 120 }; 121 if m.is_dir() { 122 fs::set_permissions(p, fs::Permissions::from_mode(0o755))?; 123 for d in fs::read_dir(p)? { 124 remove(&d?.path())?; 125 } 126 fs::remove_dir(p) 127 } else { 128 fs::remove_file(p) 129 } 130} 131 132#[cfg(test)] 133mod tests { 134 use super::*; 135 136 #[test] 137 fn hash_matches_nix() { 138 // nix hash convert --to nix32 sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= 139 let d = Algorithm::SHA256.digest(b""); 140 assert!(hash_matches( 141 "sha256:0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73", 142 &d 143 )); 144 assert!(hash_matches( 145 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 146 &d 147 )); 148 assert!(!hash_matches( 149 "0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73", 150 &d 151 )); 152 } 153 154 #[tokio::test] 155 async fn unpack_and_list() { 156 let tmp = std::env::temp_dir().join(format!("lazy-store-test-{}", std::process::id())); 157 let (src, dir) = (tmp.join("src"), tmp.join("out")); 158 let _ = remove(&tmp); 159 fs::create_dir_all(&src).unwrap(); 160 fs::write(src.join("bin"), "#!/bin/sh\n").unwrap(); 161 fs::set_permissions(src.join("bin"), fs::Permissions::from_mode(0o755)).unwrap(); 162 std::os::unix::fs::symlink("bin", src.join("link")).unwrap(); 163 let data: Vec<u8> = harmonia_file_nar::NarByteStream::new(src.clone()) 164 .try_collect::<Vec<_>>() 165 .await 166 .unwrap() 167 .concat(); 168 169 let (h, n) = unpack(&data[..], &dir).await.unwrap(); 170 assert_eq!(n, data.len() as u64); 171 assert_eq!(h, Algorithm::SHA256.digest(&data)); 172 assert_eq!(fs::metadata(dir.join("bin")).unwrap().mode() & 0o777, 0o555); 173 assert_eq!(fs::metadata(&dir).unwrap().mode() & 0o777, 0o555); 174 let t = from_path(&dir).unwrap(); 175 let ls = br#"{"version":1,"root":{"type":"directory","entries":{ 176 "bin":{"type":"regular","size":10,"executable":true,"narOffset":96}, 177 "link":{"type":"symlink","target":"bin"}}}}"#; 178 assert_eq!(from_listing(ls).unwrap(), t); 179 remove(&dir).unwrap(); 180 181 // as cache.lassul.us serves empty directories 182 let ls = br#"{"version":1,"root":{"type":"directory","entries":{ 183 "empty":{"type":"directory"}}}}"#; 184 fs::create_dir_all(dir.join("empty")).unwrap(); 185 assert_eq!(from_listing(ls).unwrap(), from_path(&dir).unwrap()); 186 assert_eq!( 187 from_listing(br#"{"version":1,"root":{"type":"directory"}}"#).unwrap(), 188 FileTree(FileSystemObject::Directory(Directory { 189 entries: BTreeMap::new() 190 })) 191 ); 192 remove(&dir).unwrap(); 193 194 // the caller's NarSize check rejects it 195 let mut trailing = data.clone(); 196 trailing.push(0); 197 let (_, n) = unpack(&trailing[..], &dir).await.unwrap(); 198 assert_eq!(n, trailing.len() as u64); 199 remove(&tmp).unwrap(); 200 } 201}