archive.rs

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