Skip to main content

libwally/
package_index.rs

1use std::collections::HashMap;
2use std::io::{BufReader, ErrorKind, Write};
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5
6use anyhow::{anyhow, Context};
7use fs_err::{create_dir_all, File, OpenOptions};
8use git2::Repository;
9use serde::{Deserialize, Serialize};
10use tempfile::TempDir;
11use url::Url;
12
13use crate::git_util;
14use crate::manifest::Manifest;
15use crate::package_name::PackageName;
16
17/// Configuration contained in the index's `config.json` file.
18#[derive(Debug, Serialize, Deserialize)]
19pub struct PackageIndexConfig {
20    pub api: Url,
21    pub github_oauth_id: Option<String>,
22
23    #[serde(default)]
24    pub fallback_registries: Vec<String>,
25}
26
27pub struct PackageIndex {
28    /// URL of the remote index.
29    url: Url,
30
31    /// The path to the contents of the index, where we can retrieve packages.
32    path: PathBuf,
33
34    /// A Git repository handle that we can use to perform operations on the
35    /// index repository, like updating or clearing it.
36    repository: Mutex<Repository>,
37
38    /// A cache that contains all of the packages we've queried so far. This
39    /// cache is never emptied.
40    package_cache: Mutex<HashMap<PackageName, Arc<PackageMetadata>>>,
41
42    /// A GitHub Personal Access Token to use before trying the machine's local
43    /// configuration.
44    access_token: Option<String>,
45
46    /// If this index is contained in a temporary location, like when running
47    /// tests or a registry server, hold onto it here so that it'll be dropped
48    /// at the right time.
49    #[allow(unused)]
50    temp_dir: Option<TempDir>,
51}
52
53impl PackageIndex {
54    pub fn new(index_url: &Url, access_token: Option<String>) -> anyhow::Result<Self> {
55        let path = index_path(index_url)?;
56        let repository = git_util::open_or_clone(access_token.clone(), index_url, &path)?;
57
58        let index = Self {
59            url: index_url.clone(),
60            path,
61            repository: Mutex::new(repository),
62            package_cache: Mutex::new(HashMap::new()),
63            access_token,
64            temp_dir: None,
65        };
66
67        index.update()?;
68        Ok(index)
69    }
70
71    pub fn url(&self) -> &Url {
72        &self.url
73    }
74
75    pub fn path(&self) -> &PathBuf {
76        &self.path
77    }
78
79    pub fn new_temp(index_url: &Url, access_token: Option<String>) -> anyhow::Result<Self> {
80        let temp_dir = tempfile::tempdir()?;
81        let path = temp_dir.path().to_owned();
82        let repository = git_util::open_or_clone(access_token.clone(), index_url, &path)?;
83
84        let index = Self {
85            url: index_url.clone(),
86            path,
87            repository: Mutex::new(repository),
88            package_cache: Mutex::new(HashMap::new()),
89            access_token,
90            temp_dir: Some(temp_dir),
91        };
92
93        index.update()?;
94        Ok(index)
95    }
96
97    pub fn update(&self) -> anyhow::Result<()> {
98        let repository = self.repository.lock().unwrap();
99
100        log::info!(
101            "Updating package index {}...",
102            repository.find_remote("origin")?.url().unwrap()
103        );
104        git_util::update_index(self.access_token.clone(), &repository)
105            .with_context(|| format!("could not update package index"))?;
106
107        Ok(())
108    }
109
110    pub fn config(&self) -> anyhow::Result<PackageIndexConfig> {
111        let config_path = self.path.join("config.json");
112        let contents = fs_err::read_to_string(config_path)?;
113        Ok(serde_json::from_str(&contents)?)
114    }
115
116    /// Publish a package to the local copy of the index and attempt to push it
117    /// to the remote index, allowing a certain number of retries.
118    ///
119    /// Note that this method does not interact with any remote registry
120    /// servers; it's intended for use with local registries or in the
121    /// implementation of the registry server itself.
122    pub fn publish(&self, manifest: &Manifest) -> anyhow::Result<()> {
123        let repo = self.repository.lock().unwrap();
124        let package_path = self.package_path(&manifest.package.name);
125
126        // This package might not exist yet, so create its containing directory.
127        create_dir_all(package_path.parent().unwrap())?;
128
129        {
130            let mut file = OpenOptions::new()
131                .append(true)
132                .create(true)
133                .open(&package_path)?;
134
135            // Package entries are newline-delimited JSON files. We assume here
136            // that the file is empty or already ends in a newline.
137            let mut entry = serde_json::to_string(&manifest)?;
138            entry.push('\n');
139            file.write_all(entry.as_bytes())?;
140        }
141
142        git_util::commit_and_push(
143            &repo,
144            self.access_token.clone(),
145            &format!("Publish {}", manifest.package_id()),
146            &self.path,
147            &package_path,
148        )?;
149
150        // Blow away the cache for this package, since we've now modified the
151        // underlying file.
152        let mut package_cache = self.package_cache.lock().unwrap();
153        package_cache.remove(&manifest.package.name);
154
155        Ok(())
156    }
157
158    /// Read the list of versions for a package from the index.
159    pub fn get_package_metadata(&self, name: &PackageName) -> anyhow::Result<Arc<PackageMetadata>> {
160        let mut package_cache = self.package_cache.lock().unwrap();
161
162        if package_cache.contains_key(name) {
163            Ok(Arc::clone(&package_cache[name]))
164        } else {
165            let package_path = self.package_path(name);
166
167            // Construct a buffered file reader, with a nice error message in the
168            // event of failure. We might want to return a structured error from
169            // this method in the future to distinguish between general I/O errors
170            // and a package not existing.
171            let file = File::open(&package_path)
172                .with_context(|| format!("could not open package {} from index", name))?;
173            let file = BufReader::new(file);
174
175            // Read all of the manifests from the package file.
176            //
177            // Entries into the index are stored as JSON Lines. This block will
178            // either parse all of the entries, or fail with a single error.
179            let manifest_stream: Result<Vec<Manifest>, serde_json::Error> =
180                serde_json::Deserializer::from_reader(file)
181                    .into_iter::<Manifest>()
182                    .collect();
183
184            let mut versions = manifest_stream
185                .with_context(|| format!("could not parse package index entry for {}", name))?;
186
187            versions.sort_by(|a, b| b.package.version.cmp(&a.package.version));
188
189            let metadata = Arc::new(PackageMetadata { versions });
190            package_cache.insert(name.clone(), Arc::clone(&metadata));
191
192            Ok(metadata)
193        }
194    }
195
196    /// Read the list of owners for a scope from the index
197    pub fn get_scope_owners(&self, scope: &str) -> anyhow::Result<Vec<u64>> {
198        let mut path = self.path.clone();
199        path.push(scope);
200        path.push("owners.json");
201
202        match File::open(path) {
203            Ok(file) => serde_json::from_reader(file)
204                .with_context(|| format!("could not parse owner file for scope {}", scope)),
205
206            Err(error) => match error.kind() {
207                ErrorKind::NotFound => Ok(Vec::new()),
208                _ => Err(error)
209                    .with_context(|| format!("failed to read owner file for scope {}", scope)),
210            },
211        }
212    }
213
214    /// Check if a user id is present in the owners.json file for a scope
215    pub fn is_scope_owner(&self, scope: &str, user_id: &u64) -> anyhow::Result<bool> {
216        let owners = self.get_scope_owners(scope)?;
217        Ok(owners.iter().any(|owner| owner == user_id))
218    }
219
220    /// Add an owner to a scope's owner file
221    /// Similar to publish, this first applies the change to our local copy
222    /// and then attempts to push it to the remote index
223    pub fn add_scope_owner(&self, scope: &str, owner_id: &u64) -> anyhow::Result<()> {
224        let repo = self.repository.lock().unwrap();
225        let mut path = self.path.clone();
226
227        // This scope might not exist yet
228        path.push(scope);
229        create_dir_all(&path)?;
230        path.push("owners.json");
231
232        {
233            let mut owners = self.get_scope_owners(&scope)?;
234            let mut file = OpenOptions::new().write(true).create(true).open(&path)?;
235
236            owners.push(*owner_id);
237            file.write_all(serde_json::to_string(&owners)?.as_bytes())?;
238        }
239
240        git_util::commit_and_push(
241            &repo,
242            self.access_token.clone(),
243            &format!("Add owner for {}/*", scope),
244            &self.path,
245            &path,
246        )?;
247
248        Ok(())
249    }
250
251    fn package_path(&self, name: &PackageName) -> PathBuf {
252        // Each package has all of its versions stored in a folder based on its
253        // scope and name.
254        let mut package_path = self.path.clone();
255        package_path.push(name.scope());
256        package_path.push(name.name());
257        package_path
258    }
259}
260
261#[derive(Default, Serialize)]
262pub struct PackageMetadata {
263    pub versions: Vec<Manifest>,
264}
265
266fn index_path(index_url: &Url) -> anyhow::Result<PathBuf> {
267    let registry_name = match (index_url.domain(), index_url.scheme()) {
268        (Some(domain), _) => domain,
269        (None, "file") => "local-registry",
270        _ => "unknown",
271    };
272
273    let hash = blake3::hash(index_url.to_string().as_bytes());
274    let hash_hex = hex::encode(&hash.as_bytes()[..8]);
275    let ident = format!("{}-{}", registry_name, hash_hex);
276
277    let path = dirs::cache_dir()
278        .ok_or_else(|| anyhow!("could not find cache directory"))?
279        .join("wally")
280        .join("index")
281        .join(ident);
282
283    Ok(path)
284}