xtask_lib/tasks/util/
fs.rs

1//! Filesystem helpers shared across xtask modules.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6
7/// Recursively copies a directory tree from `src` to `dst`.
8///
9/// # Errors
10///
11/// Returns an error if any directory cannot be created or any file cannot be
12/// copied.
13pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
14    std::fs::create_dir_all(dst)?;
15    for entry in std::fs::read_dir(src)? {
16        let entry = entry?;
17        let src_path = entry.path();
18        let dst_path = dst.join(entry.file_name());
19        if src_path.is_dir() {
20            copy_dir_all(&src_path, &dst_path)?;
21        } else {
22            std::fs::copy(&src_path, &dst_path).with_context(|| {
23                format!(
24                    "failed to copy {} → {}",
25                    src_path.display(),
26                    dst_path.display()
27                )
28            })?;
29        }
30    }
31    Ok(())
32}
33
34/// Creates a `.tar.gz` archive at `dest` from `entries` inside `base_dir`.
35///
36/// # Errors
37///
38/// Returns an error if the archive cannot be created or any file cannot be
39/// added.
40pub fn create_tarball(dest: &Path, base_dir: &Path, entries: &[&str]) -> Result<()> {
41    if let Some(parent) = dest.parent() {
42        std::fs::create_dir_all(parent)
43            .with_context(|| format!("failed to create parent directory for {}", dest.display()))?;
44    }
45
46    let file = std::fs::File::create(dest)
47        .with_context(|| format!("failed to create archive {}", dest.display()))?;
48
49    let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
50    let mut builder = tar::Builder::new(gz);
51
52    for entry in entries {
53        let entry_path = base_dir.join(entry);
54
55        if entry_path.is_dir() {
56            builder
57                .append_dir_all(entry, &entry_path)
58                .with_context(|| format!("failed to add directory {entry} to archive"))?;
59            continue;
60        }
61
62        builder
63            .append_path_with_name(&entry_path, entry)
64            .with_context(|| format!("failed to add file {entry} to archive"))?;
65    }
66
67    builder
68        .into_inner()
69        .context("failed to finalise tar builder")?
70        .finish()
71        .context("failed to finalise gzip stream")?;
72
73    Ok(())
74}
75
76/// Extracts a `.tar.gz` archive into `dest_dir`.
77///
78/// # Errors
79///
80/// Returns an error if the archive cannot be opened, decompressed, or
81/// extracted.
82pub fn extract_tarball(src: &Path, dest_dir: &Path) -> Result<()> {
83    std::fs::create_dir_all(dest_dir).with_context(|| {
84        format!(
85            "failed to create destination directory {}",
86            dest_dir.display()
87        )
88    })?;
89
90    let file = std::fs::File::open(src)
91        .with_context(|| format!("failed to open archive {}", src.display()))?;
92
93    let gz = flate2::read::GzDecoder::new(file);
94    let mut archive = tar::Archive::new(gz);
95
96    archive.unpack(dest_dir).with_context(|| {
97        format!(
98            "failed to extract {} into {}",
99            src.display(),
100            dest_dir.display()
101        )
102    })
103}
104
105/// Extracts a `.tar.gz` archive, stripping the first path component.
106///
107/// # Errors
108///
109/// Returns an error if the archive cannot be opened, decompressed, or
110/// extracted.
111pub fn extract_tarball_strip_one(src: &Path, dest_dir: &Path) -> Result<()> {
112    std::fs::create_dir_all(dest_dir).with_context(|| {
113        format!(
114            "failed to create destination directory {}",
115            dest_dir.display()
116        )
117    })?;
118
119    let file = std::fs::File::open(src)
120        .with_context(|| format!("failed to open archive {}", src.display()))?;
121
122    let gz = flate2::read::GzDecoder::new(file);
123    let mut archive = tar::Archive::new(gz);
124
125    for entry in archive
126        .entries()
127        .with_context(|| format!("failed to read entries from {}", src.display()))?
128    {
129        let mut entry =
130            entry.with_context(|| format!("failed to read entry from {}", src.display()))?;
131
132        let entry_path = entry
133            .path()
134            .with_context(|| format!("entry in {} has no path", src.display()))?
135            .into_owned();
136
137        let stripped = entry_path
138            .components()
139            .skip(1)
140            .collect::<std::path::PathBuf>();
141
142        if stripped.as_os_str().is_empty() {
143            continue;
144        }
145
146        let dest = dest_dir.join(&stripped);
147
148        if let Some(parent) = dest.parent() {
149            std::fs::create_dir_all(parent)
150                .with_context(|| format!("failed to create directory {}", parent.display()))?;
151        }
152
153        entry
154            .unpack(&dest)
155            .with_context(|| format!("failed to unpack entry to {}", dest.display()))?;
156    }
157
158    Ok(())
159}
160
161/// Extracts only entries from a `.tar.gz` archive whose paths start with one
162/// of the given `prefixes`, placing them under `dest_dir`.
163///
164/// Path components beginning with `./` are normalised before matching.
165///
166/// # Errors
167///
168/// Returns an error if the archive cannot be opened, decompressed, or
169/// extracted.
170pub fn extract_tarball_paths(src: &Path, dest_dir: &Path, prefixes: &[&str]) -> Result<()> {
171    std::fs::create_dir_all(dest_dir).with_context(|| {
172        format!(
173            "failed to create destination directory {}",
174            dest_dir.display()
175        )
176    })?;
177
178    let file = std::fs::File::open(src)
179        .with_context(|| format!("failed to open archive {}", src.display()))?;
180
181    let gz = flate2::read::GzDecoder::new(file);
182    let mut archive = tar::Archive::new(gz);
183
184    for entry in archive
185        .entries()
186        .with_context(|| format!("failed to read entries from {}", src.display()))?
187    {
188        let mut entry =
189            entry.with_context(|| format!("failed to read entry from {}", src.display()))?;
190
191        let entry_path = entry
192            .path()
193            .with_context(|| format!("entry in {} has no path", src.display()))?
194            .into_owned();
195
196        let normalised = entry_path
197            .strip_prefix("./")
198            .unwrap_or(&entry_path)
199            .to_string_lossy();
200
201        let matches = prefixes.iter().any(|prefix| {
202            normalised == *prefix
203                || normalised.starts_with(&format!("{prefix}/"))
204                || normalised.starts_with(&format!("{prefix}\\"))
205        });
206
207        if !matches {
208            continue;
209        }
210
211        let dest = dest_dir.join(normalised.as_ref());
212
213        if let Some(parent) = dest.parent() {
214            std::fs::create_dir_all(parent)
215                .with_context(|| format!("failed to create directory {}", parent.display()))?;
216        }
217
218        entry
219            .unpack(&dest)
220            .with_context(|| format!("failed to unpack entry to {}", dest.display()))?;
221    }
222
223    Ok(())
224}
225
226/// Extracts only entries from a `.zip` archive whose paths start with one of
227/// the given `prefixes`, placing them under `dest_dir`.
228///
229/// # Errors
230///
231/// Returns an error if the archive cannot be opened or extracted.
232pub fn extract_zip_paths(src: &Path, dest_dir: &Path, prefixes: &[&str]) -> Result<()> {
233    std::fs::create_dir_all(dest_dir).with_context(|| {
234        format!(
235            "failed to create destination directory {}",
236            dest_dir.display()
237        )
238    })?;
239
240    let file = std::fs::File::open(src)
241        .with_context(|| format!("failed to open archive {}", src.display()))?;
242
243    let mut archive = zip::ZipArchive::new(file)
244        .with_context(|| format!("failed to read zip {}", src.display()))?;
245
246    for i in 0..archive.len() {
247        let mut entry = archive
248            .by_index(i)
249            .with_context(|| format!("failed to read entry {i} from {}", src.display()))?;
250
251        let name = entry.name().to_owned();
252        let matches = prefixes
253            .iter()
254            .any(|prefix| name == *prefix || name.starts_with(&format!("{prefix}/")));
255
256        if !matches {
257            continue;
258        }
259
260        let dest = dest_dir.join(&name);
261
262        if entry.is_dir() {
263            std::fs::create_dir_all(&dest)
264                .with_context(|| format!("failed to create directory {}", dest.display()))?;
265            continue;
266        }
267
268        if let Some(parent) = dest.parent() {
269            std::fs::create_dir_all(parent)
270                .with_context(|| format!("failed to create directory {}", parent.display()))?;
271        }
272
273        #[cfg(unix)]
274        let unix_mode = entry.unix_mode();
275        let mut out = std::fs::File::create(&dest)
276            .with_context(|| format!("failed to create file {}", dest.display()))?;
277        std::io::copy(&mut entry, &mut out)
278            .with_context(|| format!("failed to write {}", dest.display()))?;
279
280        #[cfg(unix)]
281        {
282            use std::os::unix::fs::PermissionsExt;
283            if let Some(mode) = unix_mode {
284                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(mode)).ok();
285            }
286        }
287    }
288
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::fs;
296
297    #[test]
298    fn copy_dir_all_copies_nested_structure() {
299        let tmp = tempfile::tempdir().unwrap();
300        let src = tmp.path().join("src");
301        let sub = src.join("sub");
302        fs::create_dir_all(&sub).unwrap();
303        fs::write(src.join("a.txt"), b"a").unwrap();
304        fs::write(sub.join("b.txt"), b"b").unwrap();
305
306        let dst = tmp.path().join("dst");
307        copy_dir_all(&src, &dst).unwrap();
308
309        assert_eq!(fs::read(dst.join("a.txt")).unwrap(), b"a");
310        assert_eq!(fs::read(dst.join("sub/b.txt")).unwrap(), b"b");
311    }
312
313    #[test]
314    fn create_tarball_writes_archive() {
315        let tmp = tempfile::tempdir().unwrap();
316
317        let src_dir = tmp.path().join("src");
318        fs::create_dir_all(&src_dir).unwrap();
319        fs::write(src_dir.join("hello.txt"), b"hello").unwrap();
320
321        let archive = tmp.path().join("out.tar.gz");
322        create_tarball(&archive, tmp.path(), &["src"]).unwrap();
323
324        assert!(archive.exists());
325    }
326
327    #[test]
328    fn create_and_extract_tarball_roundtrip() {
329        let tmp = tempfile::tempdir().unwrap();
330
331        let src_dir = tmp.path().join("src");
332        fs::create_dir_all(&src_dir).unwrap();
333        fs::write(src_dir.join("hello.txt"), b"hello").unwrap();
334
335        let archive = tmp.path().join("out.tar.gz");
336        create_tarball(&archive, tmp.path(), &["src"]).unwrap();
337
338        let extract_dir = tmp.path().join("extracted");
339        fs::create_dir_all(&extract_dir).unwrap();
340        extract_tarball(&archive, &extract_dir).unwrap();
341
342        assert!(extract_dir.join("src/hello.txt").exists());
343    }
344
345    #[test]
346    fn extract_zip_paths_extracts_only_matching_prefixes() {
347        let tmp = tempfile::tempdir().unwrap();
348
349        let zip_path = tmp.path().join("assets.zip");
350        let file = std::fs::File::create(&zip_path).unwrap();
351        let mut writer = zip::ZipWriter::new(file);
352        let options = zip::write::SimpleFileOptions::default()
353            .compression_method(zip::CompressionMethod::Stored);
354
355        writer.add_directory("bin/", options).unwrap();
356        writer.start_file("bin/tool", options).unwrap();
357        std::io::Write::write_all(&mut writer, b"tool binary").unwrap();
358
359        writer.add_directory("other/", options).unwrap();
360        writer.start_file("other/skip.txt", options).unwrap();
361        std::io::Write::write_all(&mut writer, b"skip me").unwrap();
362
363        writer.finish().unwrap();
364
365        let extract_dir = tmp.path().join("extracted");
366        extract_zip_paths(&zip_path, &extract_dir, &["bin"]).unwrap();
367
368        assert!(extract_dir.join("bin/tool").exists());
369        assert!(!extract_dir.join("other/skip.txt").exists());
370    }
371
372    #[test]
373    fn extract_tarball_strip_one_removes_top_level_dir() {
374        let tmp = tempfile::tempdir().unwrap();
375
376        let src_dir = tmp.path().join("toplevel");
377        fs::create_dir_all(&src_dir).unwrap();
378        fs::write(src_dir.join("file.txt"), b"content").unwrap();
379
380        let archive = tmp.path().join("strip.tar.gz");
381        create_tarball(&archive, tmp.path(), &["toplevel"]).unwrap();
382
383        let extract_dir = tmp.path().join("stripped");
384        fs::create_dir_all(&extract_dir).unwrap();
385        extract_tarball_strip_one(&archive, &extract_dir).unwrap();
386
387        assert!(extract_dir.join("file.txt").exists());
388    }
389
390    #[test]
391    fn extract_tarball_paths_extracts_only_matching_prefixes() {
392        let tmp = tempfile::tempdir().unwrap();
393
394        let libs_dir = tmp.path().join("libs");
395        let other_dir = tmp.path().join("other");
396        fs::create_dir_all(&libs_dir).unwrap();
397        fs::create_dir_all(&other_dir).unwrap();
398        fs::write(libs_dir.join("libfoo.so"), b"lib").unwrap();
399        fs::write(other_dir.join("skip.txt"), b"skip").unwrap();
400
401        let archive = tmp.path().join("out.tar.gz");
402        create_tarball(&archive, tmp.path(), &["libs", "other"]).unwrap();
403
404        let extract_dir = tmp.path().join("extracted");
405        extract_tarball_paths(&archive, &extract_dir, &["libs"]).unwrap();
406
407        assert!(extract_dir.join("libs/libfoo.so").exists());
408        assert!(!extract_dir.join("other/skip.txt").exists());
409    }
410}