typst_kit/
package.rs

1//! Download and unpack packages and package indices.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use ecow::eco_format;
7use once_cell::sync::OnceCell;
8use serde::Deserialize;
9use typst_library::diag::{bail, PackageError, PackageResult, StrResult};
10use typst_syntax::package::{PackageSpec, PackageVersion, VersionlessPackageSpec};
11
12use crate::download::{Downloader, Progress};
13
14/// The default Typst registry.
15pub const DEFAULT_REGISTRY: &str = "https://packages.typst.org";
16
17/// The public namespace in the default Typst registry.
18pub const DEFAULT_NAMESPACE: &str = "preview";
19
20/// The default packages sub directory within the package and package cache paths.
21pub const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages";
22
23/// Holds information about where packages should be stored and downloads them
24/// on demand, if possible.
25#[derive(Debug)]
26pub struct PackageStorage {
27    /// The path at which non-local packages should be stored when downloaded.
28    package_cache_path: Option<PathBuf>,
29    /// The path at which local packages are stored.
30    package_path: Option<PathBuf>,
31    /// The downloader used for fetching the index and packages.
32    downloader: Downloader,
33    /// The cached index of the default namespace.
34    index: OnceCell<Vec<serde_json::Value>>,
35}
36
37impl PackageStorage {
38    /// Creates a new package storage for the given package paths. Falls back to
39    /// the recommended XDG directories if they are `None`.
40    pub fn new(
41        package_cache_path: Option<PathBuf>,
42        package_path: Option<PathBuf>,
43        downloader: Downloader,
44    ) -> Self {
45        Self::with_index(package_cache_path, package_path, downloader, OnceCell::new())
46    }
47
48    /// Creates a new package storage with a pre-defined index.
49    ///
50    /// Useful for testing.
51    fn with_index(
52        package_cache_path: Option<PathBuf>,
53        package_path: Option<PathBuf>,
54        downloader: Downloader,
55        index: OnceCell<Vec<serde_json::Value>>,
56    ) -> Self {
57        Self {
58            package_cache_path: package_cache_path.or_else(|| {
59                dirs::cache_dir().map(|cache_dir| cache_dir.join(DEFAULT_PACKAGES_SUBDIR))
60            }),
61            package_path: package_path.or_else(|| {
62                dirs::data_dir().map(|data_dir| data_dir.join(DEFAULT_PACKAGES_SUBDIR))
63            }),
64            downloader,
65            index,
66        }
67    }
68
69    /// Returns the path at which non-local packages should be stored when
70    /// downloaded.
71    pub fn package_cache_path(&self) -> Option<&Path> {
72        self.package_cache_path.as_deref()
73    }
74
75    /// Returns the path at which local packages are stored.
76    pub fn package_path(&self) -> Option<&Path> {
77        self.package_path.as_deref()
78    }
79
80    /// Make a package available in the on-disk.
81    pub fn prepare_package(
82        &self,
83        spec: &PackageSpec,
84        progress: &mut dyn Progress,
85    ) -> PackageResult<PathBuf> {
86        let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
87
88        if let Some(packages_dir) = &self.package_path {
89            let dir = packages_dir.join(&subdir);
90            if dir.exists() {
91                return Ok(dir);
92            }
93        }
94
95        if let Some(cache_dir) = &self.package_cache_path {
96            let dir = cache_dir.join(&subdir);
97            if dir.exists() {
98                return Ok(dir);
99            }
100
101            // Download from network if it doesn't exist yet.
102            if spec.namespace == DEFAULT_NAMESPACE {
103                self.download_package(spec, &dir, progress)?;
104                if dir.exists() {
105                    return Ok(dir);
106                }
107            }
108        }
109
110        Err(PackageError::NotFound(spec.clone()))
111    }
112
113    /// Try to determine the latest version of a package.
114    pub fn determine_latest_version(
115        &self,
116        spec: &VersionlessPackageSpec,
117    ) -> StrResult<PackageVersion> {
118        if spec.namespace == DEFAULT_NAMESPACE {
119            // For `DEFAULT_NAMESPACE`, download the package index and find the latest
120            // version.
121            self.download_index()?
122                .iter()
123                .filter_map(|value| MinimalPackageInfo::deserialize(value).ok())
124                .filter(|package| package.name == spec.name)
125                .map(|package| package.version)
126                .max()
127                .ok_or_else(|| eco_format!("failed to find package {spec}"))
128        } else {
129            // For other namespaces, search locally. We only search in the data
130            // directory and not the cache directory, because the latter is not
131            // intended for storage of local packages.
132            let subdir = format!("{}/{}", spec.namespace, spec.name);
133            self.package_path
134                .iter()
135                .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok())
136                .flatten()
137                .filter_map(|entry| entry.ok())
138                .map(|entry| entry.path())
139                .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
140                .max()
141                .ok_or_else(|| eco_format!("please specify the desired version"))
142        }
143    }
144
145    /// Download the package index. The result of this is cached for efficiency.
146    pub fn download_index(&self) -> StrResult<&[serde_json::Value]> {
147        self.index
148            .get_or_try_init(|| {
149                let url = format!("{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/index.json");
150                match self.downloader.download(&url) {
151                    Ok(response) => response.into_json().map_err(|err| {
152                        eco_format!("failed to parse package index: {err}")
153                    }),
154                    Err(ureq::Error::Status(404, _)) => {
155                        bail!("failed to fetch package index (not found)")
156                    }
157                    Err(err) => bail!("failed to fetch package index ({err})"),
158                }
159            })
160            .map(AsRef::as_ref)
161    }
162
163    /// Download a package over the network.
164    ///
165    /// # Panics
166    /// Panics if the package spec namespace isn't `DEFAULT_NAMESPACE`.
167    pub fn download_package(
168        &self,
169        spec: &PackageSpec,
170        package_dir: &Path,
171        progress: &mut dyn Progress,
172    ) -> PackageResult<()> {
173        assert_eq!(spec.namespace, DEFAULT_NAMESPACE);
174
175        let url = format!(
176            "{DEFAULT_REGISTRY}/{DEFAULT_NAMESPACE}/{}-{}.tar.gz",
177            spec.name, spec.version
178        );
179
180        let data = match self.downloader.download_with_progress(&url, progress) {
181            Ok(data) => data,
182            Err(ureq::Error::Status(404, _)) => {
183                if let Ok(version) = self.determine_latest_version(&spec.versionless()) {
184                    return Err(PackageError::VersionNotFound(spec.clone(), version));
185                } else {
186                    return Err(PackageError::NotFound(spec.clone()));
187                }
188            }
189            Err(err) => {
190                return Err(PackageError::NetworkFailed(Some(eco_format!("{err}"))))
191            }
192        };
193
194        let decompressed = flate2::read::GzDecoder::new(data.as_slice());
195        tar::Archive::new(decompressed).unpack(package_dir).map_err(|err| {
196            fs::remove_dir_all(package_dir).ok();
197            PackageError::MalformedArchive(Some(eco_format!("{err}")))
198        })
199    }
200}
201
202/// Minimal information required about a package to determine its latest
203/// version.
204#[derive(Deserialize)]
205struct MinimalPackageInfo {
206    name: String,
207    version: PackageVersion,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn lazy_deser_index() {
216        let storage = PackageStorage::with_index(
217            None,
218            None,
219            Downloader::new("typst/test"),
220            OnceCell::with_value(vec![
221                serde_json::json!({
222                    "name": "charged-ieee",
223                    "version": "0.1.0",
224                    "entrypoint": "lib.typ",
225                }),
226                serde_json::json!({
227                    "name": "unequivocal-ams",
228                    // This version number is currently not valid, so this package
229                    // can't be parsed.
230                    "version": "0.2.0-dev",
231                    "entrypoint": "lib.typ",
232                }),
233            ]),
234        );
235
236        let ieee_version = storage.determine_latest_version(&VersionlessPackageSpec {
237            namespace: "preview".into(),
238            name: "charged-ieee".into(),
239        });
240        assert_eq!(ieee_version, Ok(PackageVersion { major: 0, minor: 1, patch: 0 }));
241
242        let ams_version = storage.determine_latest_version(&VersionlessPackageSpec {
243            namespace: "preview".into(),
244            name: "unequivocal-ams".into(),
245        });
246        assert_eq!(
247            ams_version,
248            Err("failed to find package @preview/unequivocal-ams".into())
249        )
250    }
251}