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}