Skip to main content

flowey_lib_common/_util/
extract.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4use flowey::node::prelude::*;
5
6const FLOWEY_INFO_DIR: &str = ".flowey_info";
7const FLOWEY_EXTRACT_DIR: &str = "extracted";
8
9#[derive(Clone)]
10#[non_exhaustive]
11pub struct ExtractZipDeps<C = VarNotClaimed> {
12    persistent_dir: Option<ReadVar<PathBuf, C>>,
13    bsdtar_installed: ReadVar<SideEffect, C>,
14}
15
16impl ClaimVar for ExtractZipDeps {
17    type Claimed = ExtractZipDeps<VarClaimed>;
18
19    fn claim(self, ctx: &mut StepCtx<'_>) -> Self::Claimed {
20        let Self {
21            persistent_dir,
22            bsdtar_installed,
23        } = self;
24        ExtractZipDeps {
25            persistent_dir: persistent_dir.claim(ctx),
26            bsdtar_installed: bsdtar_installed.claim(ctx),
27        }
28    }
29}
30
31#[track_caller]
32pub fn extract_zip_if_new_deps(ctx: &mut NodeCtx<'_>) -> ExtractZipDeps {
33    let platform = ctx.platform();
34    ExtractZipDeps {
35        persistent_dir: ctx.persistent_dir(),
36        bsdtar_installed: ctx.reqv(|v| crate::install_dist_pkg::Request::Install {
37            package_names: match platform {
38                FlowPlatform::Linux(linux_distribution) => match linux_distribution {
39                    FlowPlatformLinuxDistro::Fedora => {
40                        vec!["bsdtar".into()]
41                    }
42                    FlowPlatformLinuxDistro::Ubuntu => vec!["libarchive-tools".into()],
43                    FlowPlatformLinuxDistro::AzureLinux | FlowPlatformLinuxDistro::Arch => {
44                        vec!["libarchive".into()]
45                    }
46                    FlowPlatformLinuxDistro::Nix => vec![],
47                    FlowPlatformLinuxDistro::Unknown => vec![],
48                },
49                _ => {
50                    vec![]
51                }
52            },
53            done: v,
54        }),
55    }
56}
57
58/// Extracts the given `file` into `persistent_dir` (or into
59/// [`std::env::current_dir()`], if no persistent dir is available).
60///
61/// To avoid redundant unzips between pipeline runs, callers must provide a
62/// `file_version` string that identifies the current file. If the
63/// previous run already unzipped a zip with the given `file_version`, this
64/// function will return nearly instantaneously.
65pub fn extract_zip_if_new(
66    rt: &mut RustRuntimeServices<'_>,
67    deps: ExtractZipDeps<VarClaimed>,
68    file: &Path,
69    file_version: &str,
70) -> anyhow::Result<PathBuf> {
71    let ExtractZipDeps {
72        persistent_dir,
73        bsdtar_installed: _,
74    } = deps;
75
76    let root_dir = match persistent_dir {
77        Some(dir) => rt.read(dir),
78        None => rt.sh.current_dir(),
79    };
80
81    let bsdtar = crate::_util::bsdtar_name(rt);
82    extract_archive_if_new(rt, &root_dir, file, file_version, bsdtar)
83}
84
85/// Extracts the given `.tar.gz` `file` into `persistent_dir` (or into
86/// [`std::env::current_dir()`], if no persistent dir is available).
87///
88/// Unlike `.tar.bz2`, `.tar.gz` is handled natively by every platform's `tar`,
89/// so this helper has no install-package dependency to track. The caller
90/// resolves the persistent dir itself and passes it (already read) as
91/// `persistent_dir` — no `Deps` struct needed.
92///
93/// To avoid redundant extracts between pipeline runs, callers must provide a
94/// `file_version` string that identifies the current file. If the previous
95/// run already extracted an archive with the given `file_version`, this
96/// function will return nearly instantaneously.
97pub fn extract_tar_gz_if_new(
98    rt: &mut RustRuntimeServices<'_>,
99    persistent_dir: Option<&Path>,
100    file: &Path,
101    file_version: &str,
102) -> anyhow::Result<PathBuf> {
103    let root_dir = match persistent_dir {
104        Some(dir) => dir.to_path_buf(),
105        None => rt.sh.current_dir(),
106    };
107
108    extract_archive_if_new(rt, &root_dir, file, file_version, "tar")
109}
110
111#[derive(Clone)]
112#[non_exhaustive]
113pub struct ExtractTarBz2Deps<C = VarNotClaimed> {
114    persistent_dir: Option<ReadVar<PathBuf, C>>,
115    bzip2_installed: ReadVar<SideEffect, C>,
116}
117
118impl ClaimVar for ExtractTarBz2Deps {
119    type Claimed = ExtractTarBz2Deps<VarClaimed>;
120
121    fn claim(self, ctx: &mut StepCtx<'_>) -> Self::Claimed {
122        let Self {
123            persistent_dir,
124            bzip2_installed,
125        } = self;
126        ExtractTarBz2Deps {
127            persistent_dir: persistent_dir.claim(ctx),
128            bzip2_installed: bzip2_installed.claim(ctx),
129        }
130    }
131}
132
133#[track_caller]
134pub fn extract_tar_bz2_if_new_deps(ctx: &mut NodeCtx<'_>) -> ExtractTarBz2Deps {
135    ExtractTarBz2Deps {
136        persistent_dir: ctx.persistent_dir(),
137        bzip2_installed: ctx.reqv(|v| crate::install_dist_pkg::Request::Install {
138            package_names: vec!["bzip2".into()],
139            done: v,
140        }),
141    }
142}
143
144/// Extracts the given `file` into `persistent_dir` (or into
145/// [`std::env::current_dir()`], if no persistent dir is available).
146///
147/// To avoid redundant extractions between pipeline runs, callers must provide a
148/// `file_version` string that identifies the current file. If the previous run
149/// already extracted an archive with the given `file_version`, this function will
150/// return nearly instantaneously.
151pub fn extract_tar_bz2_if_new(
152    rt: &mut RustRuntimeServices<'_>,
153    deps: ExtractTarBz2Deps<VarClaimed>,
154    file: &Path,
155    file_version: &str,
156) -> anyhow::Result<PathBuf> {
157    let ExtractTarBz2Deps {
158        persistent_dir,
159        bzip2_installed: _,
160    } = deps;
161
162    let root_dir = match persistent_dir {
163        Some(dir) => rt.read(dir),
164        None => rt.sh.current_dir(),
165    };
166
167    extract_archive_if_new(rt, &root_dir, file, file_version, "tar")
168}
169
170fn extract_archive_if_new(
171    rt: &mut RustRuntimeServices<'_>,
172    root_dir: &Path,
173    file: &Path,
174    file_version: &str,
175    tar: &str,
176) -> anyhow::Result<PathBuf> {
177    let current_dir = rt.sh.current_dir();
178    let file = current_dir.join(file).absolute()?;
179    let root_dir = current_dir.join(root_dir).absolute()?;
180    extract_if_new(&root_dir, &file, file_version, |extract_dir| {
181        let _dir = rt.sh.push_dir(extract_dir);
182        flowey::shell_cmd!(rt, "{tar} -xf {file}").run()?;
183        Ok(())
184    })
185}
186
187fn extract_if_new(
188    root_dir: &Path,
189    file: &Path,
190    file_version: &str,
191    extract: impl FnOnce(&Path) -> anyhow::Result<()>,
192) -> anyhow::Result<PathBuf> {
193    let filename = file
194        .file_name()
195        .with_context(|| format!("archive path has no filename: {}", file.display()))?;
196    let extract_dir = root_dir.join(FLOWEY_EXTRACT_DIR).join(filename);
197    let pkg_info_dir = root_dir.join(FLOWEY_INFO_DIR);
198    let pkg_info_file = pkg_info_dir.join(filename);
199
200    let cached_version = match fs_err::read_to_string(&pkg_info_file) {
201        Ok(info) => Some(info),
202        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
203        Err(err) => return Err(err).context("failed to read archive extraction version"),
204    };
205    let extracted = match fs_err::metadata(&extract_dir) {
206        Ok(metadata) => {
207            anyhow::ensure!(
208                metadata.is_dir(),
209                "archive extraction path is not a directory: {}",
210                extract_dir.display()
211            );
212            true
213        }
214        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
215        Err(err) => return Err(err).context("failed to inspect archive extraction directory"),
216    };
217
218    if extracted && cached_version.as_deref() == Some(file_version) {
219        log::info!("already extracted!");
220        return Ok(extract_dir);
221    }
222
223    // Invalidate the old marker before replacing files, so a failed extraction
224    // cannot leave a partial directory that looks like a cache hit.
225    if cached_version.is_some() {
226        fs_err::remove_file(&pkg_info_file)?;
227    }
228    if extracted {
229        fs_err::remove_dir_all(&extract_dir)?;
230    }
231    fs_err::create_dir_all(&extract_dir)?;
232    extract(&extract_dir)
233        .with_context(|| format!("failed to extract archive {}", file.display()))?;
234    fs_err::create_dir_all(&pkg_info_dir)?;
235    fs_err::write(pkg_info_file, file_version)?;
236
237    Ok(extract_dir)
238}