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 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}