1use serde::{Deserialize, Serialize};
22use tatara_lisp::DeriveTataraDomain;
23
24use crate::SpecError;
25use crate::store_ops::{NarNode, ParsedNar};
26
27#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
30#[tatara(keyword = "defstore-transform")]
31pub struct StoreTransform {
32 pub name: String,
34 pub description: String,
36 #[serde(rename = "matchKind")]
38 pub match_kind: TransformKind,
39 pub pattern: String,
42 pub replacement: String,
44}
45
46#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum TransformKind {
49 FileContents,
51 StorePathReference,
54 EntryName,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct TransformOutcome {
62 pub transform_name: String,
64 pub file_rewrites: usize,
66 pub ref_rewrites: usize,
68 pub entries_renamed: usize,
70}
71
72impl TransformOutcome {
73 #[must_use]
76 pub fn is_noop(&self) -> bool {
77 self.file_rewrites == 0
78 && self.ref_rewrites == 0
79 && self.entries_renamed == 0
80 }
81}
82
83pub fn apply_all(
92 tree: &mut ParsedNar,
93 transforms: &[StoreTransform],
94) -> Result<Vec<TransformOutcome>, SpecError> {
95 let mut outcomes = Vec::with_capacity(transforms.len());
96 for t in transforms {
97 let outcome = apply_one(&mut tree.root, t)?;
98 outcomes.push(outcome);
99 }
100 Ok(outcomes)
101}
102
103pub fn apply_one(
109 node: &mut NarNode,
110 transform: &StoreTransform,
111) -> Result<TransformOutcome, SpecError> {
112 match transform.match_kind {
113 TransformKind::FileContents => {
114 let re = regex::bytes::Regex::new(&transform.pattern)
115 .map_err(|e| SpecError::Interp {
116 phase: "transform-bad-regex".into(),
117 message: format!("{}: {e}", transform.name),
118 })?;
119 let replacement = transform.replacement.as_bytes();
120 let mut count = 0usize;
121 walk_files(node, &mut |contents| {
122 let new_bytes = re.replace_all(contents, replacement).into_owned();
123 if new_bytes != *contents {
124 count += 1;
125 *contents = new_bytes;
126 }
127 });
128 Ok(TransformOutcome {
129 transform_name: transform.name.clone(),
130 file_rewrites: count,
131 ..Default::default()
132 })
133 }
134 TransformKind::StorePathReference => {
135 if transform.pattern.len() != transform.replacement.len() {
140 return Err(SpecError::Interp {
141 phase: "transform-bad-prefix".into(),
142 message: format!(
143 "{}: StorePathReference requires same-length pattern + replacement (got {} vs {})",
144 transform.name,
145 transform.pattern.len(),
146 transform.replacement.len(),
147 ),
148 });
149 }
150 let from = transform.pattern.as_bytes();
151 let to = transform.replacement.as_bytes();
152 let mut count = 0usize;
153 walk_files(node, &mut |contents| {
154 let mut new_bytes = Vec::with_capacity(contents.len());
155 let mut i = 0;
156 while i < contents.len() {
157 if i + from.len() <= contents.len()
158 && &contents[i..i + from.len()] == from
159 {
160 new_bytes.extend_from_slice(to);
161 count += 1;
162 i += from.len();
163 } else {
164 new_bytes.push(contents[i]);
165 i += 1;
166 }
167 }
168 *contents = new_bytes;
169 });
170 walk_symlinks(node, &mut |target| {
171 if target.contains(&transform.pattern) {
172 *target = target.replace(&transform.pattern, &transform.replacement);
173 count += 1;
174 }
175 });
176 Ok(TransformOutcome {
177 transform_name: transform.name.clone(),
178 ref_rewrites: count,
179 ..Default::default()
180 })
181 }
182 TransformKind::EntryName => {
183 let re = regex::Regex::new(&transform.pattern)
184 .map_err(|e| SpecError::Interp {
185 phase: "transform-bad-regex".into(),
186 message: format!("{}: {e}", transform.name),
187 })?;
188 let replacement = &transform.replacement;
189 let mut count = 0usize;
190 if let NarNode::Directory { entries } = node {
191 for (name, _) in entries.iter_mut() {
192 let new_name = re.replace_all(name, replacement.as_str()).into_owned();
193 if new_name != *name {
194 *name = new_name;
195 count += 1;
196 }
197 }
198 entries.sort_by(|a, b| a.0.cmp(&b.0));
200 }
201 Ok(TransformOutcome {
202 transform_name: transform.name.clone(),
203 entries_renamed: count,
204 ..Default::default()
205 })
206 }
207 }
208}
209
210fn walk_files<F: FnMut(&mut Vec<u8>)>(node: &mut NarNode, f: &mut F) {
213 match node {
214 NarNode::File { contents, .. } => f(contents),
215 NarNode::Directory { entries } => {
216 for (_, child) in entries.iter_mut() {
217 walk_files(child, f);
218 }
219 }
220 NarNode::Symlink { .. } => {}
221 }
222}
223
224fn walk_symlinks<F: FnMut(&mut String)>(node: &mut NarNode, f: &mut F) {
226 match node {
227 NarNode::Symlink { target } => f(target),
228 NarNode::Directory { entries } => {
229 for (_, child) in entries.iter_mut() {
230 walk_symlinks(child, f);
231 }
232 }
233 NarNode::File { .. } => {}
234 }
235}
236
237pub const CANONICAL_STORE_TRANSFORMS_LISP: &str =
240 include_str!("../specs/store_transforms.lisp");
241
242pub fn load_canonical() -> Result<Vec<StoreTransform>, SpecError> {
248 crate::loader::load_all::<StoreTransform>(CANONICAL_STORE_TRANSFORMS_LISP)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::store_ops::NarNode;
255
256 fn file(s: &[u8]) -> NarNode {
257 NarNode::File { executable: false, contents: s.to_vec() }
258 }
259 fn dir(entries: Vec<(&str, NarNode)>) -> NarNode {
260 NarNode::Directory {
261 entries: entries.into_iter().map(|(n, e)| (n.to_string(), e)).collect(),
262 }
263 }
264 fn symlink(target: &str) -> NarNode {
265 NarNode::Symlink { target: target.to_string() }
266 }
267
268 #[test]
269 fn canonical_transforms_parse() {
270 let xs = load_canonical().unwrap();
271 assert!(!xs.is_empty());
272 }
273
274 #[test]
275 fn file_contents_regex_rewrites() {
276 let mut tree = ParsedNar { root: dir(vec![
277 ("a", file(b"hello world")),
278 ("b", file(b"world peace")),
279 ]) };
280 let t = StoreTransform {
281 name: "test".into(),
282 description: "world → planet".into(),
283 match_kind: TransformKind::FileContents,
284 pattern: "world".into(),
285 replacement: "planet".into(),
286 };
287 let out = apply_one(&mut tree.root, &t).unwrap();
288 assert_eq!(out.file_rewrites, 2);
289
290 if let NarNode::Directory { entries } = &tree.root {
291 if let NarNode::File { contents, .. } = &entries[0].1 {
292 assert_eq!(contents, b"hello planet");
293 }
294 if let NarNode::File { contents, .. } = &entries[1].1 {
295 assert_eq!(contents, b"planet peace");
296 }
297 }
298 }
299
300 #[test]
301 fn store_path_reference_rewrites_in_files_and_symlinks() {
302 let mut tree = ParsedNar { root: dir(vec![
303 ("file", file(b"link to /nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x")),
304 ("ln", symlink("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x")),
305 ]) };
306 let t = StoreTransform {
307 name: "graft".into(),
308 description: "".into(),
309 match_kind: TransformKind::StorePathReference,
310 pattern: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
311 replacement: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
312 };
313 let out = apply_one(&mut tree.root, &t).unwrap();
314 assert_eq!(out.ref_rewrites, 2); if let NarNode::Directory { entries } = &tree.root {
317 if let NarNode::File { contents, .. } = &entries[0].1 {
318 assert!(contents.windows(32).any(|w| w == b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
319 }
320 if let NarNode::Symlink { target } = &entries[1].1 {
321 assert_eq!(target, "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-x");
322 }
323 }
324 }
325
326 #[test]
327 fn store_path_reference_rejects_different_length() {
328 let mut tree = ParsedNar { root: file(b"") };
329 let t = StoreTransform {
330 name: "bad-len".into(),
331 description: "".into(),
332 match_kind: TransformKind::StorePathReference,
333 pattern: "short".into(),
334 replacement: "longer".into(),
335 };
336 let err = apply_one(&mut tree.root, &t).unwrap_err();
337 match err {
338 SpecError::Interp { phase, .. } => assert_eq!(phase, "transform-bad-prefix"),
339 _ => panic!("expected transform-bad-prefix"),
340 }
341 }
342
343 #[test]
344 fn entry_name_renames_top_level_only() {
345 let mut tree = ParsedNar { root: dir(vec![
346 ("old-x", file(b"")),
347 ("old-y", file(b"")),
348 ]) };
349 let t = StoreTransform {
350 name: "rename".into(),
351 description: "".into(),
352 match_kind: TransformKind::EntryName,
353 pattern: "^old-".into(),
354 replacement: "new-".into(),
355 };
356 let out = apply_one(&mut tree.root, &t).unwrap();
357 assert_eq!(out.entries_renamed, 2);
358 if let NarNode::Directory { entries } = &tree.root {
359 assert_eq!(entries[0].0, "new-x");
360 assert_eq!(entries[1].0, "new-y");
361 }
362 }
363
364 #[test]
365 fn idempotent_reapply_is_noop() {
366 let mut tree = ParsedNar { root: file(b"hello world") };
367 let t = StoreTransform {
368 name: "test".into(),
369 description: "".into(),
370 match_kind: TransformKind::FileContents,
371 pattern: "world".into(),
372 replacement: "planet".into(),
373 };
374 let first = apply_one(&mut tree.root, &t).unwrap();
375 let second = apply_one(&mut tree.root, &t).unwrap();
376 assert_eq!(first.file_rewrites, 1);
377 assert!(second.is_noop());
378 }
379}