forked from
lass.pds.lassul.us/superconfig
This repository has no description
6.3 kB
181 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": {...}}`).
33pub fn from_listing(body: &[u8]) -> io::Result<Tree> {
34 #[derive(Deserialize)]
35 struct Listing {
36 root: Tree,
37 }
38 let l: Listing = serde_json::from_slice(body).map_err(io::Error::other)?;
39 Ok(l.root)
40}
41
42/// Listing of an unpacked store path (for paths without `.ls`).
43pub fn from_path(p: &Path) -> io::Result<Tree> {
44 let m = fs::symlink_metadata(p)?;
45 let t = m.file_type();
46 Ok(FileTree(if t.is_dir() {
47 let mut entries = BTreeMap::new();
48 for d in fs::read_dir(p)? {
49 let d = d?;
50 let name = d.file_name().into_string().map_err(|n| {
51 io::Error::other(format!("non-UTF-8 name {n:?} in {}", p.display()))
52 })?;
53 entries.insert(name, Box::new(from_path(&d.path())?));
54 }
55 FileSystemObject::Directory(Directory { entries })
56 } else if t.is_symlink() {
57 let target = fs::read_link(p)?.to_string_lossy().into_owned();
58 FileSystemObject::Symlink(Symlink { target })
59 } else {
60 FileSystemObject::Regular(Regular {
61 executable: m.mode() & 0o100 != 0,
62 contents: Size { size: m.size() },
63 })
64 }))
65}
66
67/// Unpack the NAR read from `r` to `dest` (which must not exist), read-only
68/// like in the store. Returns the NAR's sha256 and size; checking them is
69/// up to the caller. Data after the NAR counts into the size.
70pub async fn unpack(r: impl AsyncRead + Unpin + Send, dest: &Path) -> io::Result<(Hash, u64)> {
71 let mut hash = Context::new(Algorithm::SHA256);
72 let mut size = 0;
73 let mut r = InspectReader::new(r, |b: &[u8]| {
74 hash.update(b);
75 size += b.len() as u64;
76 });
77 let events =
78 parse_nar(&mut r).map_err(|e| NarWriteError::create_file_error(dest.to_owned(), e));
79 restore(events, dest).await.map_err(io::Error::other)?;
80 r.read(&mut [0; 1]).await?;
81 drop(r);
82 let d = dest.to_owned();
83 tokio::task::spawn_blocking(move || read_only(&d))
84 .await
85 .map_err(io::Error::other)??;
86 Ok((hash.finish(), size))
87}
88
89/// Store permissions: 0444 or 0555 for files, 0555 for directories.
90fn read_only(p: &Path) -> io::Result<()> {
91 let m = fs::symlink_metadata(p)?;
92 let mode = if m.is_symlink() {
93 return Ok(());
94 } else if m.is_dir() {
95 for d in fs::read_dir(p)? {
96 read_only(&d?.path())?;
97 }
98 0o555
99 } else if m.mode() & 0o100 != 0 {
100 0o555
101 } else {
102 0o444
103 };
104 fs::set_permissions(p, fs::Permissions::from_mode(mode))
105}
106
107/// Remove a (partially) unpacked tree, including read-only directories.
108pub fn remove(p: &Path) -> io::Result<()> {
109 let m = match fs::symlink_metadata(p) {
110 Ok(m) => m,
111 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
112 Err(e) => return Err(e),
113 };
114 if m.is_dir() {
115 fs::set_permissions(p, fs::Permissions::from_mode(0o755))?;
116 for d in fs::read_dir(p)? {
117 remove(&d?.path())?;
118 }
119 fs::remove_dir(p)
120 } else {
121 fs::remove_file(p)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn hash_matches_nix() {
131 // nix hash convert --to nix32 sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
132 let d = Algorithm::SHA256.digest(b"");
133 assert!(hash_matches(
134 "sha256:0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73",
135 &d
136 ));
137 assert!(hash_matches(
138 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
139 &d
140 ));
141 assert!(!hash_matches(
142 "0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73",
143 &d
144 ));
145 }
146
147 #[tokio::test]
148 async fn unpack_and_list() {
149 let tmp = std::env::temp_dir().join(format!("lazy-store-test-{}", std::process::id()));
150 let (src, dir) = (tmp.join("src"), tmp.join("out"));
151 let _ = remove(&tmp);
152 fs::create_dir_all(&src).unwrap();
153 fs::write(src.join("bin"), "#!/bin/sh\n").unwrap();
154 fs::set_permissions(src.join("bin"), fs::Permissions::from_mode(0o755)).unwrap();
155 std::os::unix::fs::symlink("bin", src.join("link")).unwrap();
156 let data: Vec<u8> = harmonia_file_nar::NarByteStream::new(src.clone())
157 .try_collect::<Vec<_>>()
158 .await
159 .unwrap()
160 .concat();
161
162 let (h, n) = unpack(&data[..], &dir).await.unwrap();
163 assert_eq!(n, data.len() as u64);
164 assert_eq!(h, Algorithm::SHA256.digest(&data));
165 assert_eq!(fs::metadata(dir.join("bin")).unwrap().mode() & 0o777, 0o555);
166 assert_eq!(fs::metadata(&dir).unwrap().mode() & 0o777, 0o555);
167 let t = from_path(&dir).unwrap();
168 let ls = br#"{"version":1,"root":{"type":"directory","entries":{
169 "bin":{"type":"regular","size":10,"executable":true,"narOffset":96},
170 "link":{"type":"symlink","target":"bin"}}}}"#;
171 assert_eq!(from_listing(ls).unwrap(), t);
172 remove(&dir).unwrap();
173
174 // the caller's NarSize check rejects it
175 let mut trailing = data.clone();
176 trailing.push(0);
177 let (_, n) = unpack(&trailing[..], &dir).await.unwrap();
178 assert_eq!(n, trailing.len() as u64);
179 remove(&tmp).unwrap();
180 }
181}