archive.rs

  1use std::path::Path;
  2
  3use anyhow::{Context as _, Result};
  4use async_zip::base::read;
  5#[cfg(not(windows))]
  6use futures::AsyncSeek;
  7use futures::{AsyncRead, io::BufReader};
  8
  9#[cfg(windows)]
 10pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
 11    let mut reader = read::stream::ZipFileReader::new(BufReader::new(reader));
 12
 13    let destination = &destination
 14        .canonicalize()
 15        .unwrap_or_else(|_| destination.to_path_buf());
 16
 17    while let Some(mut item) = reader.next_with_entry().await? {
 18        let entry_reader = item.reader_mut();
 19        let entry = entry_reader.entry();
 20        let path = destination.join(
 21            entry
 22                .filename()
 23                .as_str()
 24                .context("reading zip entry file name")?,
 25        );
 26
 27        if entry
 28            .dir()
 29            .with_context(|| format!("reading zip entry metadata for path {path:?}"))?
 30        {
 31            std::fs::create_dir_all(&path)
 32                .with_context(|| format!("creating directory {path:?}"))?;
 33        } else {
 34            let parent_dir = path
 35                .parent()
 36                .with_context(|| format!("no parent directory for {path:?}"))?;
 37            std::fs::create_dir_all(parent_dir)
 38                .with_context(|| format!("creating parent directory {parent_dir:?}"))?;
 39            let mut file = smol::fs::File::create(&path)
 40                .await
 41                .with_context(|| format!("creating file {path:?}"))?;
 42            futures::io::copy(entry_reader, &mut file)
 43                .await
 44                .with_context(|| format!("extracting into file {path:?}"))?;
 45        }
 46
 47        reader = item.skip().await.context("reading next zip entry")?;
 48    }
 49
 50    Ok(())
 51}
 52
 53#[cfg(not(windows))]
 54pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
 55    // Unix needs file permissions copied when extracting.
 56    // This is only possible to do when a reader impls `AsyncSeek` and `seek::ZipFileReader` is used.
 57    // `stream::ZipFileReader` also has the `unix_permissions` method, but it will always return `Some(0)`.
 58    //
 59    // A typical `reader` comes from a streaming network response, so cannot be sought right away,
 60    // and reading the entire archive into the memory seems wasteful.
 61    //
 62    // So, save the stream into a temporary file first and then get it read with a seeking reader.
 63    let mut file = async_fs::File::from(tempfile::tempfile().context("creating a temporary file")?);
 64    futures::io::copy(&mut BufReader::new(reader), &mut file)
 65        .await
 66        .context("saving archive contents into the temporary file")?;
 67    extract_seekable_zip(destination, file).await
 68}
 69
 70#[cfg(not(windows))]
 71pub async fn extract_seekable_zip<R: AsyncRead + AsyncSeek + Unpin>(
 72    destination: &Path,
 73    reader: R,
 74) -> Result<()> {
 75    let mut reader = read::seek::ZipFileReader::new(BufReader::new(reader))
 76        .await
 77        .context("reading the zip archive")?;
 78    let destination = &destination
 79        .canonicalize()
 80        .unwrap_or_else(|_| destination.to_path_buf());
 81    for (i, entry) in reader.file().entries().to_vec().into_iter().enumerate() {
 82        let path = destination.join(
 83            entry
 84                .filename()
 85                .as_str()
 86                .context("reading zip entry file name")?,
 87        );
 88
 89        if entry
 90            .dir()
 91            .with_context(|| format!("reading zip entry metadata for path {path:?}"))?
 92        {
 93            std::fs::create_dir_all(&path)
 94                .with_context(|| format!("creating directory {path:?}"))?;
 95        } else {
 96            let parent_dir = path
 97                .parent()
 98                .with_context(|| format!("no parent directory for {path:?}"))?;
 99            std::fs::create_dir_all(parent_dir)
100                .with_context(|| format!("creating parent directory {parent_dir:?}"))?;
101            let mut file = smol::fs::File::create(&path)
102                .await
103                .with_context(|| format!("creating file {path:?}"))?;
104            let mut entry_reader = reader
105                .reader_with_entry(i)
106                .await
107                .with_context(|| format!("reading entry for path {path:?}"))?;
108            futures::io::copy(&mut entry_reader, &mut file)
109                .await
110                .with_context(|| format!("extracting into file {path:?}"))?;
111
112            if let Some(perms) = entry.unix_permissions()
113                && perms != 0o000
114            {
115                use std::os::unix::fs::PermissionsExt;
116                let permissions = std::fs::Permissions::from_mode(u32::from(perms));
117                file.set_permissions(permissions)
118                    .await
119                    .with_context(|| format!("setting permissions for file {path:?}"))?;
120            }
121        }
122    }
123
124    Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129    use async_zip::ZipEntryBuilder;
130    use async_zip::base::write::ZipFileWriter;
131    use futures::{AsyncSeek, AsyncWriteExt};
132    use smol::io::Cursor;
133    use tempfile::TempDir;
134
135    use super::*;
136
137    #[allow(unused_variables)]
138    async fn compress_zip(src_dir: &Path, dst: &Path, keep_file_permissions: bool) -> Result<()> {
139        let mut out = smol::fs::File::create(dst).await?;
140        let mut writer = ZipFileWriter::new(&mut out);
141
142        for entry in walkdir::WalkDir::new(src_dir) {
143            let entry = entry?;
144            let path = entry.path();
145
146            if path.is_dir() {
147                continue;
148            }
149
150            let relative_path = path.strip_prefix(src_dir)?;
151            let data = smol::fs::read(&path).await?;
152
153            let filename = relative_path.display().to_string();
154
155            #[cfg(unix)]
156            {
157                let mut builder =
158                    ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
159                use std::os::unix::fs::PermissionsExt;
160                let metadata = std::fs::metadata(path)?;
161                let perms = keep_file_permissions.then(|| metadata.permissions().mode() as u16);
162                builder = builder.unix_permissions(perms.unwrap_or_default());
163                writer.write_entry_whole(builder, &data).await?;
164            }
165            #[cfg(not(unix))]
166            {
167                let builder =
168                    ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
169                writer.write_entry_whole(builder, &data).await?;
170            }
171        }
172
173        writer.close().await?;
174        out.flush().await?;
175        out.sync_all().await?;
176
177        Ok(())
178    }
179
180    #[track_caller]
181    fn assert_file_content(path: &Path, content: &str) {
182        assert!(path.exists(), "file not found: {:?}", path);
183        let actual = std::fs::read_to_string(path).unwrap();
184        assert_eq!(actual, content);
185    }
186
187    #[track_caller]
188    fn make_test_data() -> TempDir {
189        let dir = tempfile::tempdir().unwrap();
190        let dst = dir.path();
191
192        std::fs::write(dst.join("test"), "Hello world.").unwrap();
193        std::fs::create_dir_all(dst.join("foo/bar")).unwrap();
194        std::fs::write(dst.join("foo/bar.txt"), "Foo bar.").unwrap();
195        std::fs::write(dst.join("foo/dar.md"), "Bar dar.").unwrap();
196        std::fs::write(dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap();
197
198        dir
199    }
200
201    async fn read_archive(path: &Path) -> impl AsyncRead + AsyncSeek + Unpin {
202        let data = smol::fs::read(&path).await.unwrap();
203        Cursor::new(data)
204    }
205
206    #[test]
207    fn test_extract_zip() {
208        let test_dir = make_test_data();
209        let zip_file = test_dir.path().join("test.zip");
210
211        smol::block_on(async {
212            compress_zip(test_dir.path(), &zip_file, true)
213                .await
214                .unwrap();
215            let reader = read_archive(&zip_file).await;
216
217            let dir = tempfile::tempdir().unwrap();
218            let dst = dir.path();
219            extract_zip(dst, reader).await.unwrap();
220
221            assert_file_content(&dst.join("test"), "Hello world.");
222            assert_file_content(&dst.join("foo/bar.txt"), "Foo bar.");
223            assert_file_content(&dst.join("foo/dar.md"), "Bar dar.");
224            assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界");
225        });
226    }
227
228    #[cfg(unix)]
229    #[test]
230    fn test_extract_zip_preserves_executable_permissions() {
231        use std::os::unix::fs::PermissionsExt;
232
233        smol::block_on(async {
234            let test_dir = tempfile::tempdir().unwrap();
235            let executable_path = test_dir.path().join("my_script");
236
237            // Create an executable file
238            std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap();
239            let mut perms = std::fs::metadata(&executable_path).unwrap().permissions();
240            perms.set_mode(0o755); // rwxr-xr-x
241            std::fs::set_permissions(&executable_path, perms).unwrap();
242
243            // Create zip
244            let zip_file = test_dir.path().join("test.zip");
245            compress_zip(test_dir.path(), &zip_file, true)
246                .await
247                .unwrap();
248
249            // Extract to new location
250            let extract_dir = tempfile::tempdir().unwrap();
251            let reader = read_archive(&zip_file).await;
252            extract_zip(extract_dir.path(), reader).await.unwrap();
253
254            // Check permissions are preserved
255            let extracted_path = extract_dir.path().join("my_script");
256            assert!(extracted_path.exists());
257            let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
258            assert_eq!(extracted_perms.mode() & 0o777, 0o755);
259        });
260    }
261
262    #[cfg(unix)]
263    #[test]
264    fn test_extract_zip_sets_default_permissions() {
265        use std::os::unix::fs::PermissionsExt;
266
267        smol::block_on(async {
268            let test_dir = tempfile::tempdir().unwrap();
269            let executable_path = test_dir.path().join("my_script");
270
271            // Create an executable file
272            std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap();
273
274            // Create zip
275            let zip_file = test_dir.path().join("test.zip");
276            compress_zip(test_dir.path(), &zip_file, false)
277                .await
278                .unwrap();
279
280            // Extract to new location
281            let extract_dir = tempfile::tempdir().unwrap();
282            let reader = read_archive(&zip_file).await;
283            extract_zip(extract_dir.path(), reader).await.unwrap();
284
285            // Check permissions are preserved
286            let extracted_path = extract_dir.path().join("my_script");
287            assert!(extracted_path.exists());
288            let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
289            assert_eq!(
290                extracted_perms.mode() & 0o777,
291                0o644,
292                "Expected default set of permissions for unzipped file with no permissions set."
293            );
294        });
295    }
296}