1use crate::cache::PackageCache;
2use crate::manifest::{DependencySpec, DetailedDep, Manifest};
3use std::path::PathBuf;
4use std::process::Command;
5
6#[derive(Debug, Clone)]
8pub struct FetchResult {
9 pub name: String,
10 pub version: String,
11 pub source_desc: String,
12 pub cache_path: PathBuf,
13}
14
15pub fn fetch_dependency(
17 name: &str,
18 spec: &DependencySpec,
19 project_root: &std::path::Path,
20 cache: &PackageCache,
21) -> Result<FetchResult, String> {
22 match spec {
23 DependencySpec::Simple(version_req) => fetch_registry(name, version_req, cache),
24 DependencySpec::Detailed(d) => {
25 if d.git.is_some() {
26 fetch_git(name, d, cache)
27 } else if d.path.is_some() {
28 fetch_path(name, d, project_root, cache)
29 } else if d.version.is_some() {
30 fetch_registry(name, d.version.as_deref().unwrap(), cache)
31 } else {
32 Err(format!(
33 "Dependency '{name}' has no source specified. \
34 Use `path = \"..\"` for local or `git = \"url\"` for remote."
35 ))
36 }
37 }
38 }
39}
40
41fn fetch_git(name: &str, dep: &DetailedDep, cache: &PackageCache) -> Result<FetchResult, String> {
43 let url = dep.git.as_deref().unwrap();
44
45 let tmp_dir = std::env::temp_dir().join(format!("tl-fetch-{name}-{}", std::process::id()));
47 if tmp_dir.exists() {
48 let _ = std::fs::remove_dir_all(&tmp_dir);
49 }
50
51 let mut cmd = Command::new("git");
53 cmd.arg("clone").arg("--depth").arg("1");
54
55 if let Some(ref branch) = dep.branch {
56 cmd.arg("--branch").arg(branch);
57 } else if let Some(ref tag) = dep.tag {
58 cmd.arg("--branch").arg(tag);
59 }
60
61 cmd.arg(url).arg(&tmp_dir);
62
63 let output = cmd
64 .output()
65 .map_err(|e| format!("Failed to run git clone for '{name}': {e}. Is git installed?"))?;
66
67 if !output.status.success() {
68 let stderr = String::from_utf8_lossy(&output.stderr);
69 let _ = std::fs::remove_dir_all(&tmp_dir);
70 return Err(format!("Git clone failed for '{name}': {stderr}"));
71 }
72
73 if let Some(ref rev) = dep.rev {
75 let checkout = Command::new("git")
76 .arg("-C")
77 .arg(&tmp_dir)
78 .arg("checkout")
79 .arg(rev)
80 .output()
81 .map_err(|e| format!("Failed to checkout rev '{rev}': {e}"))?;
82
83 if !checkout.status.success() {
84 let stderr = String::from_utf8_lossy(&checkout.stderr);
85 let _ = std::fs::remove_dir_all(&tmp_dir);
86 return Err(format!(
87 "Git checkout failed for '{name}' rev '{rev}': {stderr}"
88 ));
89 }
90 }
91
92 let rev_output = Command::new("git")
94 .arg("-C")
95 .arg(&tmp_dir)
96 .arg("rev-parse")
97 .arg("HEAD")
98 .output()
99 .map_err(|e| format!("Failed to get git rev: {e}"))?;
100
101 let rev = String::from_utf8_lossy(&rev_output.stdout)
102 .trim()
103 .to_string();
104
105 let version = read_package_version(&tmp_dir, name)?;
107
108 let cache_dir = cache.package_dir(name, &version);
110 if cache_dir.exists() {
111 let _ = std::fs::remove_dir_all(&cache_dir);
112 }
113 std::fs::create_dir_all(cache_dir.parent().unwrap())
114 .map_err(|e| format!("Failed to create cache dir: {e}"))?;
115 copy_dir_recursive(&tmp_dir, &cache_dir)?;
116
117 let git_dir = cache_dir.join(".git");
119 if git_dir.exists() {
120 let _ = std::fs::remove_dir_all(&git_dir);
121 }
122
123 let _ = std::fs::remove_dir_all(&tmp_dir);
125
126 let source_desc = crate::lockfile::LockedPackage::git_source(url, &rev);
127
128 Ok(FetchResult {
129 name: name.to_string(),
130 version,
131 source_desc,
132 cache_path: cache_dir,
133 })
134}
135
136fn fetch_path(
138 name: &str,
139 dep: &DetailedDep,
140 project_root: &std::path::Path,
141 cache: &PackageCache,
142) -> Result<FetchResult, String> {
143 let raw_path = dep.path.as_deref().unwrap();
144 let abs_path = if std::path::Path::new(raw_path).is_absolute() {
145 PathBuf::from(raw_path)
146 } else {
147 project_root.join(raw_path)
148 };
149
150 let canonical = abs_path.canonicalize().map_err(|e| {
151 format!(
152 "Path dependency '{name}' at '{}' not found: {e}",
153 abs_path.display()
154 )
155 })?;
156
157 let manifest_path = canonical.join("tl.toml");
159 if !manifest_path.exists() {
160 return Err(format!(
161 "Path dependency '{name}' at '{}' has no tl.toml",
162 canonical.display()
163 ));
164 }
165
166 let version = read_package_version(&canonical, name)?;
167 let source_desc = crate::lockfile::LockedPackage::path_source(&canonical.to_string_lossy());
168
169 let cache_dir = cache.package_dir(name, &version);
171 if cache_dir.exists() {
172 let _ = std::fs::remove_dir_all(&cache_dir);
173 }
174 std::fs::create_dir_all(cache_dir.parent().unwrap())
175 .map_err(|e| format!("Failed to create cache dir: {e}"))?;
176
177 #[cfg(unix)]
179 {
180 std::os::unix::fs::symlink(&canonical, &cache_dir)
181 .map_err(|e| format!("Failed to symlink path dependency: {e}"))?;
182 }
183 #[cfg(not(unix))]
184 {
185 copy_dir_recursive(&canonical, &cache_dir)?;
186 }
187
188 Ok(FetchResult {
189 name: name.to_string(),
190 version,
191 source_desc,
192 cache_path: cache_dir,
193 })
194}
195
196fn fetch_registry(
198 name: &str,
199 version_req: &str,
200 cache: &PackageCache,
201) -> Result<FetchResult, String> {
202 #[cfg(feature = "registry")]
203 {
204 fetch_registry_impl(name, version_req, cache)
205 }
206 #[cfg(not(feature = "registry"))]
207 {
208 let _ = cache;
209 Err(format!(
210 "Package registry is not yet available.\n\
211 Cannot fetch '{name}' version '{version_req}' from registry.\n\
212 \n\
213 Use one of these alternatives:\n\
214 - Git dependency: tl add {name} --git https://github.com/user/{name}.git\n\
215 - Path dependency: tl add {name} --path ../path/to/{name}"
216 ))
217 }
218}
219
220#[cfg(feature = "registry")]
221fn fetch_registry_impl(
222 name: &str,
223 version_req: &str,
224 cache: &PackageCache,
225) -> Result<FetchResult, String> {
226 use crate::version::VersionReq;
227
228 let info = crate::registry_client::get_package_info(name)?;
230
231 let req = VersionReq::parse(version_req)?;
233 let matching = info
234 .versions
235 .iter()
236 .filter(|v| crate::version::Version::parse(&v.version).is_ok_and(|ver| req.matches(&ver)))
237 .last(); let version_entry = matching
240 .ok_or_else(|| format!("No version of '{name}' matches requirement '{version_req}'"))?;
241
242 let version = &version_entry.version;
243
244 let tarball = crate::registry_client::download_package(name, version)?;
246
247 {
249 use sha2::{Digest, Sha256};
250 let mut hasher = Sha256::new();
251 hasher.update(&tarball);
252 let hash = format!("{:x}", hasher.finalize());
253 if hash != version_entry.sha256 {
254 return Err(format!(
255 "SHA-256 mismatch for '{name}' v{version}: expected {}, got {hash}",
256 version_entry.sha256
257 ));
258 }
259 }
260
261 let cache_dir = cache.package_dir(name, version);
263 if cache_dir.exists() {
264 let _ = std::fs::remove_dir_all(&cache_dir);
265 }
266 std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
267
268 {
269 use flate2::read::GzDecoder;
270 use tar::Archive;
271 let decoder = GzDecoder::new(tarball.as_slice());
272 let mut archive = Archive::new(decoder);
273
274 let canonical_cache = cache_dir
276 .canonicalize()
277 .map_err(|e| format!("Failed to canonicalize cache dir: {e}"))?;
278 for entry in archive
279 .entries()
280 .map_err(|e| format!("Failed to read archive entries: {e}"))?
281 {
282 let entry = entry.map_err(|e| format!("Failed to read archive entry: {e}"))?;
283 let entry_path = entry
284 .path()
285 .map_err(|e| format!("Failed to read entry path: {e}"))?;
286 for component in entry_path.components() {
288 if let std::path::Component::ParentDir = component {
289 return Err(format!(
290 "Malicious archive: entry '{}' contains path traversal",
291 entry_path.display()
292 ));
293 }
294 }
295 let full_path = canonical_cache.join(&entry_path);
297 if !full_path.starts_with(&canonical_cache) {
298 return Err(format!(
299 "Malicious archive: entry '{}' escapes cache directory",
300 entry_path.display()
301 ));
302 }
303 }
304
305 let decoder2 = GzDecoder::new(tarball.as_slice());
307 let mut archive2 = Archive::new(decoder2);
308 archive2
309 .unpack(&cache_dir)
310 .map_err(|e| format!("Failed to extract package: {e}"))?;
311 }
312
313 let source_desc = format!(
314 "registry+{}@{version}",
315 crate::registry_client::registry_url()
316 );
317
318 Ok(FetchResult {
319 name: name.to_string(),
320 version: version.clone(),
321 source_desc,
322 cache_path: cache_dir,
323 })
324}
325
326pub fn read_package_manifest(dir: &std::path::Path) -> Option<Manifest> {
329 let manifest_path = dir.join("tl.toml");
330 if !manifest_path.exists() {
331 return None;
332 }
333 let content = std::fs::read_to_string(&manifest_path).ok()?;
334 toml::from_str(&content).ok()
335}
336
337fn read_package_version(dir: &std::path::Path, name: &str) -> Result<String, String> {
339 let manifest_path = dir.join("tl.toml");
340 if !manifest_path.exists() {
341 return Ok("0.0.0".to_string());
342 }
343 let content = std::fs::read_to_string(&manifest_path)
344 .map_err(|e| format!("Failed to read tl.toml for '{name}': {e}"))?;
345 let manifest: Manifest = toml::from_str(&content)
346 .map_err(|e| format!("Failed to parse tl.toml for '{name}': {e}"))?;
347 Ok(manifest.project.version)
348}
349
350fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
352 std::fs::create_dir_all(dst)
353 .map_err(|e| format!("Failed to create dir '{}': {e}", dst.display()))?;
354
355 for entry in std::fs::read_dir(src)
356 .map_err(|e| format!("Failed to read dir '{}': {e}", src.display()))?
357 {
358 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
359 let src_path = entry.path();
360 let dst_path = dst.join(entry.file_name());
361
362 if src_path.is_dir() {
363 copy_dir_recursive(&src_path, &dst_path)?;
364 } else {
365 std::fs::copy(&src_path, &dst_path).map_err(|e| {
366 format!(
367 "Failed to copy '{}' to '{}': {e}",
368 src_path.display(),
369 dst_path.display()
370 )
371 })?;
372 }
373 }
374 Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 fn make_test_package(dir: &std::path::Path, name: &str, version: &str) {
383 std::fs::create_dir_all(dir.join("src")).unwrap();
384 std::fs::write(
385 dir.join("tl.toml"),
386 format!("[project]\nname = \"{name}\"\nversion = \"{version}\"\n"),
387 )
388 .unwrap();
389 std::fs::write(
390 dir.join("src/lib.tl"),
391 "pub fn hello() { print(\"hello\") }\n",
392 )
393 .unwrap();
394 }
395
396 #[test]
397 fn fetch_path_valid() {
398 let tmp = TempDir::new().unwrap();
399 let project_root = tmp.path().join("project");
400 let lib_dir = tmp.path().join("mylib");
401 std::fs::create_dir_all(&project_root).unwrap();
402 make_test_package(&lib_dir, "mylib", "1.0.0");
403
404 let cache = PackageCache::new(tmp.path().join("cache"));
405 cache.ensure_dir().unwrap();
406
407 let spec = DependencySpec::Detailed(DetailedDep {
408 version: None,
409 git: None,
410 branch: None,
411 tag: None,
412 rev: None,
413 path: Some(lib_dir.to_string_lossy().into()),
414 });
415
416 let result = fetch_dependency("mylib", &spec, &project_root, &cache).unwrap();
417 assert_eq!(result.name, "mylib");
418 assert_eq!(result.version, "1.0.0");
419 assert!(result.source_desc.starts_with("path+"));
420 }
421
422 #[test]
423 fn fetch_path_invalid() {
424 let tmp = TempDir::new().unwrap();
425 let cache = PackageCache::new(tmp.path().join("cache"));
426
427 let spec = DependencySpec::Detailed(DetailedDep {
428 version: None,
429 git: None,
430 branch: None,
431 tag: None,
432 rev: None,
433 path: Some("/nonexistent/path".into()),
434 });
435
436 let result = fetch_dependency("missing", &spec, tmp.path(), &cache);
437 assert!(result.is_err());
438 }
439
440 #[test]
441 fn fetch_registry_error() {
442 let tmp = TempDir::new().unwrap();
443 let cache = PackageCache::new(tmp.path().join("cache"));
444
445 let spec = DependencySpec::Simple("1.0".into());
446 let result = fetch_dependency("somepkg", &spec, tmp.path(), &cache);
447 assert!(result.is_err());
448 let err = result.unwrap_err();
449 assert!(err.contains("registry is not yet available"));
450 assert!(err.contains("--git"));
451 assert!(err.contains("--path"));
452 }
453
454 #[test]
455 fn fetch_result_format() {
456 let result = FetchResult {
457 name: "test".into(),
458 version: "1.0.0".into(),
459 source_desc: "path+/tmp/test".into(),
460 cache_path: PathBuf::from("/cache/test/1.0.0"),
461 };
462 assert_eq!(result.name, "test");
463 assert_eq!(result.version, "1.0.0");
464 }
465
466 #[test]
467 fn read_version_from_manifest() {
468 let tmp = TempDir::new().unwrap();
469 make_test_package(tmp.path(), "mypkg", "2.3.4");
470 let version = read_package_version(tmp.path(), "mypkg").unwrap();
471 assert_eq!(version, "2.3.4");
472 }
473}