Skip to main content

tl_package/
fetch.rs

1use crate::cache::PackageCache;
2use crate::manifest::{DependencySpec, DetailedDep, Manifest};
3use std::path::PathBuf;
4use std::process::Command;
5
6/// Result of fetching a dependency.
7#[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
15/// Fetch a dependency into the cache based on its spec.
16pub 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
41/// Fetch from a git repository.
42fn fetch_git(name: &str, dep: &DetailedDep, cache: &PackageCache) -> Result<FetchResult, String> {
43    let url = dep.git.as_deref().unwrap();
44
45    // Create a temporary clone directory
46    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    // Build git clone command
52    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 a specific rev is requested, check it out
74    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    // Get the current rev hash
93    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    // Read tl.toml from the cloned repo to get version
106    let version = read_package_version(&tmp_dir, name)?;
107
108    // Copy to cache
109    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    // Clean up .git directory in cache
118    let git_dir = cache_dir.join(".git");
119    if git_dir.exists() {
120        let _ = std::fs::remove_dir_all(&git_dir);
121    }
122
123    // Clean up tmp
124    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
136/// Fetch from a local path.
137fn 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    // Validate tl.toml exists
158    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    // For path deps, we store a symlink or direct reference in cache
170    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    // Create symlink for path deps (so changes are reflected immediately)
178    #[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
196/// Registry fetch — downloads from the package registry when the `registry` feature is enabled.
197fn 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    // Get package info from registry
229    let info = crate::registry_client::get_package_info(name)?;
230
231    // Find the best matching version
232    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(); // latest matching version
238
239    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    // Download tarball
245    let tarball = crate::registry_client::download_package(name, version)?;
246
247    // Verify hash
248    {
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    // Extract to cache
262    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        // Validate that all entries stay within cache_dir to prevent path traversal
275        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            // Reject entries with '..' components
287            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            // Verify resolved path stays within cache_dir
296            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        // Re-extract now that validation passed
306        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
326/// Read and parse the full manifest (tl.toml) from a package directory.
327/// Returns None if no tl.toml exists.
328pub 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
337/// Read the version from a package's tl.toml.
338fn 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
350/// Recursively copy a directory.
351fn 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}