zed_util/
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                use std::os::unix::fs::PermissionsExt;
114                let permissions = std::fs::Permissions::from_mode(u32::from(perms));
115                file.set_permissions(permissions)
116                    .await
117                    .with_context(|| format!("setting permissions for file {path:?}"))?;
118            }
119        }
120    }
121
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use async_zip::ZipEntryBuilder;
128    use async_zip::base::write::ZipFileWriter;
129    use futures::{AsyncSeek, AsyncWriteExt};
130    use smol::io::Cursor;
131    use tempfile::TempDir;
132
133    use super::*;
134
135    async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> {
136        let mut out = smol::fs::File::create(dst).await?;
137        let mut writer = ZipFileWriter::new(&mut out);
138
139        for entry in walkdir::WalkDir::new(src_dir) {
140            let entry = entry?;
141            let path = entry.path();
142
143            if path.is_dir() {
144                continue;
145            }
146
147            let relative_path = path.strip_prefix(src_dir)?;
148            let data = smol::fs::read(&path).await?;
149
150            let filename = relative_path.display().to_string();
151
152            #[cfg(unix)]
153            {
154                let mut builder =
155                    ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
156                use std::os::unix::fs::PermissionsExt;
157                let metadata = std::fs::metadata(path)?;
158                let perms = metadata.permissions().mode() as u16;
159                builder = builder.unix_permissions(perms);
160                writer.write_entry_whole(builder, &data).await?;
161            }
162            #[cfg(not(unix))]
163            {
164                let builder =
165                    ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
166                writer.write_entry_whole(builder, &data).await?;
167            }
168        }
169
170        writer.close().await?;
171        out.flush().await?;
172
173        Ok(())
174    }
175
176    #[track_caller]
177    fn assert_file_content(path: &Path, content: &str) {
178        assert!(path.exists(), "file not found: {:?}", path);
179        let actual = std::fs::read_to_string(path).unwrap();
180        assert_eq!(actual, content);
181    }
182
183    #[track_caller]
184    fn make_test_data() -> TempDir {
185        let dir = tempfile::tempdir().unwrap();
186        let dst = dir.path();
187
188        std::fs::write(dst.join("test"), "Hello world.").unwrap();
189        std::fs::create_dir_all(dst.join("foo/bar")).unwrap();
190        std::fs::write(dst.join("foo/bar.txt"), "Foo bar.").unwrap();
191        std::fs::write(dst.join("foo/dar.md"), "Bar dar.").unwrap();
192        std::fs::write(dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap();
193
194        dir
195    }
196
197    async fn read_archive(path: &Path) -> impl AsyncRead + AsyncSeek + Unpin {
198        let data = smol::fs::read(&path).await.unwrap();
199        Cursor::new(data)
200    }
201
202    #[test]
203    fn test_extract_zip() {
204        let test_dir = make_test_data();
205        let zip_file = test_dir.path().join("test.zip");
206
207        smol::block_on(async {
208            compress_zip(test_dir.path(), &zip_file).await.unwrap();
209            let reader = read_archive(&zip_file).await;
210
211            let dir = tempfile::tempdir().unwrap();
212            let dst = dir.path();
213            extract_zip(dst, reader).await.unwrap();
214
215            assert_file_content(&dst.join("test"), "Hello world.");
216            assert_file_content(&dst.join("foo/bar.txt"), "Foo bar.");
217            assert_file_content(&dst.join("foo/dar.md"), "Bar dar.");
218            assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界");
219        });
220    }
221
222    #[cfg(unix)]
223    #[test]
224    fn test_extract_zip_preserves_executable_permissions() {
225        use std::os::unix::fs::PermissionsExt;
226
227        smol::block_on(async {
228            let test_dir = tempfile::tempdir().unwrap();
229            let executable_path = test_dir.path().join("my_script");
230
231            // Create an executable file
232            std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap();
233            let mut perms = std::fs::metadata(&executable_path).unwrap().permissions();
234            perms.set_mode(0o755); // rwxr-xr-x
235            std::fs::set_permissions(&executable_path, perms).unwrap();
236
237            // Create zip
238            let zip_file = test_dir.path().join("test.zip");
239            compress_zip(test_dir.path(), &zip_file).await.unwrap();
240
241            // Extract to new location
242            let extract_dir = tempfile::tempdir().unwrap();
243            let reader = read_archive(&zip_file).await;
244            extract_zip(extract_dir.path(), reader).await.unwrap();
245
246            // Check permissions are preserved
247            let extracted_path = extract_dir.path().join("my_script");
248            assert!(extracted_path.exists());
249            let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
250            assert_eq!(extracted_perms.mode() & 0o777, 0o755);
251        });
252    }
253}