Skip to main content

lust/packages/
archive.rs

1use std::{
2    env,
3    ffi::OsStr,
4    fs, io,
5    path::{Path, PathBuf},
6    process::{Command, Output},
7    time::{SystemTime, UNIX_EPOCH},
8};
9use thiserror::Error;
10use toml::Value;
11
12const SKIP_PATTERNS: &[&str] = &[
13    "target",
14    ".git",
15    ".hg",
16    ".svn",
17    ".idea",
18    ".vscode",
19    "node_modules",
20    "__pycache__",
21    ".DS_Store",
22];
23
24#[derive(Debug, Error)]
25pub enum ArchiveError {
26    #[error("package root {0} does not exist")]
27    RootMissing(PathBuf),
28
29    #[error("failed to spawn tar command: {0}")]
30    Spawn(#[from] io::Error),
31
32    #[error("tar command failed with status {status}: {stderr}")]
33    CommandFailed { status: i32, stderr: String },
34
35    #[error("failed to stage package contents: {source}")]
36    StageIo {
37        #[source]
38        source: io::Error,
39    },
40
41    #[error("failed to sanitize manifest {path}: {source}")]
42    SanitizeParse {
43        path: PathBuf,
44        #[source]
45        source: toml::de::Error,
46    },
47
48    #[error("failed to sanitize manifest {path}: {source}")]
49    SanitizeSerialize {
50        path: PathBuf,
51        #[source]
52        source: toml::ser::Error,
53    },
54
55    #[error("failed to sanitize manifest {path}: {source}")]
56    SanitizeIo {
57        path: PathBuf,
58        #[source]
59        source: io::Error,
60    },
61}
62
63#[derive(Debug)]
64pub struct PackageArchive {
65    path: PathBuf,
66}
67
68impl PackageArchive {
69    pub fn path(&self) -> &Path {
70        &self.path
71    }
72
73    pub fn into_path(self) -> PathBuf {
74        let path = self.path.clone();
75        std::mem::forget(self);
76        path
77    }
78}
79
80impl Drop for PackageArchive {
81    fn drop(&mut self) {
82        let _ = fs::remove_file(&self.path);
83    }
84}
85
86pub fn build_package_archive(root: &Path) -> Result<PackageArchive, ArchiveError> {
87    if !root.exists() {
88        return Err(ArchiveError::RootMissing(root.to_path_buf()));
89    }
90    let staging_dir = create_staging_dir().map_err(|source| ArchiveError::StageIo { source })?;
91    let archive_result = (|| -> Result<PackageArchive, ArchiveError> {
92        copy_project(root, &staging_dir)?;
93        sanitize_manifests(&staging_dir)?;
94        let output_path = temp_archive_path();
95        let mut command = Command::new(resolve_tar_command());
96        command.arg("-czf");
97        command.arg(&output_path);
98        for pattern in SKIP_PATTERNS {
99            command.arg(format!("--exclude={pattern}"));
100        }
101        command.arg("-C");
102        command.arg(&staging_dir);
103        command.arg(".");
104        let output = command.output()?;
105        ensure_success(output)?;
106        Ok(PackageArchive { path: output_path })
107    })();
108    let cleanup_result = fs::remove_dir_all(&staging_dir);
109    match (archive_result, cleanup_result) {
110        (Ok(archive), Ok(())) => Ok(archive),
111        (Ok(_), Err(err)) => Err(ArchiveError::StageIo { source: err }),
112        (Err(err), _) => Err(err),
113    }
114}
115
116fn resolve_tar_command() -> &'static str {
117    #[cfg(target_os = "windows")]
118    {
119        "tar.exe"
120    }
121    #[cfg(not(target_os = "windows"))]
122    {
123        "tar"
124    }
125}
126
127fn temp_archive_path() -> PathBuf {
128    let mut path = env::temp_dir();
129    let timestamp = SystemTime::now()
130        .duration_since(UNIX_EPOCH)
131        .unwrap_or_default()
132        .as_micros();
133    let pid = std::process::id();
134    path.push(format!("lust-package-{pid}-{timestamp}.tar.gz"));
135    path
136}
137
138fn ensure_success(output: Output) -> Result<(), ArchiveError> {
139    if output.status.success() {
140        Ok(())
141    } else {
142        let code = output.status.code().unwrap_or(-1);
143        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
144        Err(ArchiveError::CommandFailed {
145            status: code,
146            stderr,
147        })
148    }
149}
150
151fn create_staging_dir() -> io::Result<PathBuf> {
152    let mut path = env::temp_dir();
153    let timestamp = SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .unwrap_or_default()
156        .as_micros();
157    let pid = std::process::id();
158    path.push(format!("lust-package-staging-{pid}-{timestamp}"));
159    fs::create_dir_all(&path)?;
160    Ok(path)
161}
162
163fn copy_project(src: &Path, dst: &Path) -> Result<(), ArchiveError> {
164    copy_recursive(src, dst)?;
165    Ok(())
166}
167
168fn copy_recursive(src: &Path, dst: &Path) -> Result<(), ArchiveError> {
169    fs::create_dir_all(dst).map_err(|source| ArchiveError::StageIo { source })?;
170    for entry in fs::read_dir(src).map_err(|source| ArchiveError::StageIo { source })? {
171        let entry = entry.map_err(|source| ArchiveError::StageIo { source })?;
172        let file_name = entry.file_name();
173        if should_skip(&file_name) {
174            continue;
175        }
176        let src_path = entry.path();
177        let dst_path = dst.join(&file_name);
178        let file_type = entry
179            .file_type()
180            .map_err(|source| ArchiveError::StageIo { source })?;
181        if file_type.is_dir() {
182            copy_recursive(&src_path, &dst_path)?;
183        } else if file_type.is_file() {
184            if let Err(source) = fs::copy(&src_path, &dst_path) {
185                return Err(ArchiveError::StageIo { source });
186            }
187        } else if file_type.is_symlink() {
188            // replicate symlink as copy of target contents for portability
189            let target =
190                fs::read_link(&src_path).map_err(|source| ArchiveError::StageIo { source })?;
191            let resolved = if target.is_absolute() {
192                target
193            } else {
194                src_path.parent().unwrap_or(src).join(target)
195            };
196            if let Err(source) = fs::copy(&resolved, &dst_path) {
197                return Err(ArchiveError::StageIo { source });
198            }
199        }
200    }
201    Ok(())
202}
203
204fn should_skip(name: &OsStr) -> bool {
205    let name = name.to_string_lossy();
206    SKIP_PATTERNS.iter().any(|pattern| name == *pattern)
207}
208
209fn sanitize_manifests(root: &Path) -> Result<(), ArchiveError> {
210    let mut stack = vec![root.to_path_buf()];
211    while let Some(dir) = stack.pop() {
212        for entry in fs::read_dir(&dir).map_err(|source| ArchiveError::StageIo { source })? {
213            let entry = entry.map_err(|source| ArchiveError::StageIo { source })?;
214            let path = entry.path();
215            if path.is_dir() {
216                stack.push(path);
217            } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
218                sanitize_manifest(&path)?;
219            }
220        }
221    }
222    Ok(())
223}
224
225fn sanitize_manifest(path: &Path) -> Result<(), ArchiveError> {
226    let original = fs::read_to_string(path).map_err(|source| ArchiveError::SanitizeIo {
227        path: path.to_path_buf(),
228        source,
229    })?;
230    let mut value: Value =
231        toml::from_str(&original).map_err(|source| ArchiveError::SanitizeParse {
232            path: path.to_path_buf(),
233            source,
234        })?;
235    let mut changed = false;
236    if let Some(table) = value.as_table_mut() {
237        sanitize_dependency_tables(table, &mut changed);
238    }
239    if changed {
240        let serialized =
241            toml::to_string_pretty(&value).map_err(|source| ArchiveError::SanitizeSerialize {
242                path: path.to_path_buf(),
243                source,
244            })?;
245        fs::write(path, serialized).map_err(|source| ArchiveError::SanitizeIo {
246            path: path.to_path_buf(),
247            source,
248        })?;
249    }
250    Ok(())
251}
252
253fn sanitize_dependency_tables(table: &mut toml::value::Table, changed: &mut bool) {
254    for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
255        if let Some(value) = table.get_mut(key) {
256            if let Value::Table(dep_table) = value {
257                sanitize_dependency_table(dep_table, changed);
258            }
259        }
260    }
261    for (_, value) in table.iter_mut() {
262        if let Value::Table(sub) = value {
263            sanitize_dependency_tables(sub, changed);
264        }
265    }
266}
267
268fn sanitize_dependency_table(table: &mut toml::value::Table, changed: &mut bool) {
269    for (_, value) in table.iter_mut() {
270        if let Value::Table(spec) = value {
271            if sanitize_spec_table(spec) {
272                *changed = true;
273            }
274        }
275    }
276}
277
278fn sanitize_spec_table(spec: &mut toml::value::Table) -> bool {
279    let has_version = spec.contains_key("version");
280    if has_version {
281        spec.remove("path").is_some()
282    } else {
283        false
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::fs;
291    use std::process::Command;
292    use tempfile::tempdir;
293
294    fn list_archive_contents(path: &Path) -> Vec<String> {
295        let output = Command::new(resolve_tar_command())
296            .arg("-tzf")
297            .arg(path)
298            .output()
299            .expect("tar -tzf");
300        assert!(output.status.success(), "tar -tzf failed");
301        String::from_utf8_lossy(&output.stdout)
302            .lines()
303            .map(|line| {
304                let trimmed = line.trim();
305                trimmed.strip_prefix("./").unwrap_or(trimmed).to_string()
306            })
307            .collect()
308    }
309
310    #[test]
311    fn archive_skips_target_directory() {
312        let dir = tempdir().unwrap();
313        let root = dir.path();
314        fs::create_dir_all(root.join("target/cache")).unwrap();
315        fs::create_dir_all(root.join("src")).unwrap();
316        fs::write(root.join("src/lib.lust"), "content").unwrap();
317        fs::write(root.join("target/cache.bin"), "ignore").unwrap();
318
319        let archive = build_package_archive(root).unwrap();
320        let entries = list_archive_contents(archive.path());
321        assert!(entries.iter().any(|entry| entry == "src/lib.lust"));
322        assert!(!entries.iter().any(|entry| entry.starts_with("target/")));
323    }
324
325    #[test]
326    fn archive_strips_path_dependencies() {
327        let dir = tempdir().unwrap();
328        let root = dir.path();
329        fs::create_dir_all(root.join("src")).unwrap();
330        fs::write(root.join("src/lib.rs"), "pub fn foo() {}\n").unwrap();
331        fs::write(
332            root.join("Cargo.toml"),
333            r#"
334                [package]
335                name = "path-test"
336                version = "0.1.0"
337                edition = "2021"
338
339                [dependencies]
340                lust = { version = "1.2.3", path = "../lust" }
341            "#,
342        )
343        .unwrap();
344
345        let archive = build_package_archive(root).unwrap();
346        let unpack = tempdir().unwrap();
347        let status = Command::new(resolve_tar_command())
348            .arg("-xzf")
349            .arg(archive.path())
350            .arg("-C")
351            .arg(unpack.path())
352            .status()
353            .expect("tar -xzf");
354        assert!(status.success(), "tar extraction failed");
355
356        let manifest_path = unpack.path().join("Cargo.toml");
357        assert!(manifest_path.exists());
358        let contents = fs::read_to_string(&manifest_path).unwrap();
359        let parsed: Value = toml::from_str(&contents).unwrap();
360        let deps = parsed
361            .get("dependencies")
362            .and_then(Value::as_table)
363            .expect("dependencies table missing");
364        let lust_entry = deps
365            .get("lust")
366            .and_then(Value::as_table)
367            .expect("lust dependency missing");
368        assert!(lust_entry.get("version").is_some());
369        assert!(
370            lust_entry.get("path").is_none(),
371            "path key should be stripped"
372        );
373    }
374}