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}