This repository has no description
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}