pact_plugin_driver/
repository.rs

1//! Module for dealing with the plugin repository
2
3use std::collections::HashMap;
4use std::fs;
5use std::fs::File;
6use std::io::{BufReader, Read, Write};
7use std::path::PathBuf;
8
9use anyhow::anyhow;
10use chrono::{DateTime, Utc};
11use reqwest::Client;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use tracing::{debug, info, warn};
16
17use crate::plugin_manager::pact_plugin_dir;
18use crate::plugin_models::PactPluginManifest;
19
20pub const DEFAULT_INDEX: &str = include_str!("../repository.index");
21pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
22
23/// Struct representing the plugin repository index file
24#[derive(Serialize, Deserialize, Debug, Clone)]
25pub struct PluginRepositoryIndex {
26  /// Version of this index file
27  pub index_version: usize,
28
29  /// File format version of the index file
30  pub format_version: usize,
31
32  /// Timestamp (in UTC) that the file was created/updated
33  pub timestamp: DateTime<Utc>,
34
35  /// Plugin entries
36  pub entries: HashMap<String, PluginEntry>
37}
38
39impl PluginRepositoryIndex {
40  /// Looks up the plugin in the index. If no version is provided, will return the latest version
41  pub fn lookup_plugin_version(&self, name: &str, version: &Option<String>) -> Option<PluginVersion> {
42    self.entries.get(name).map(|entry| {
43      let version = if let Some(version) = version {
44        debug!("Installing plugin {}/{} from index", name, version);
45        version.as_str()
46      } else {
47        debug!("Installing plugin {}/latest from index", name);
48        entry.latest_version.as_str()
49      };
50      entry.versions.iter()
51        .find(|v| v.version == version)
52    })
53      .flatten()
54      .map(|entry| entry.clone())
55  }
56}
57
58/// Struct to store the plugin version entries
59#[derive(Serialize, Deserialize, Debug, Clone)]
60pub struct PluginEntry {
61  /// Name of the plugin
62  pub name: String,
63  /// Latest version
64  pub latest_version: String,
65  /// All the plugin versions
66  pub versions: Vec<PluginVersion>
67}
68
69/// Struct to store the plugin versions
70#[derive(Serialize, Deserialize, Debug, Clone)]
71pub struct PluginVersion {
72  /// Version of the plugin
73  pub version: String,
74  /// Source the manifest was loaded from
75  pub source: ManifestSource,
76  /// Manifest
77  pub manifest: Option<PactPluginManifest>
78}
79
80/// Source that the plugin is loaded from
81#[derive(Serialize, Deserialize, Debug, Clone)]
82#[serde(tag = "type", content = "value")]
83pub enum ManifestSource {
84  /// Loaded from a file
85  File(String),
86
87  /// Loaded from a GitHub release
88  GitHubRelease(String)
89}
90
91impl ManifestSource {
92  /// Returns the name of the plugin source
93  pub fn name(&self) -> String {
94    match self {
95      ManifestSource::File(_) => "file".to_string(),
96      ManifestSource::GitHubRelease(_) => "GitHub release".to_string()
97    }
98  }
99
100  /// Returns the associated value for the plugin source. For example, for a file source, returns
101  /// the file path.
102  pub fn value(&self) -> String {
103    match self {
104      ManifestSource::File(v) => v.clone(),
105      ManifestSource::GitHubRelease(v) => v.clone()
106    }
107  }
108}
109
110impl PluginEntry {
111  /// Create a new plugin entry from the provided manifest and source
112  pub fn new(manifest: &PactPluginManifest, source: &ManifestSource) -> PluginEntry {
113    PluginEntry {
114      name: manifest.name.clone(),
115      latest_version: manifest.version.clone(),
116      versions: vec![PluginVersion {
117        version: manifest.version.clone(),
118        source: source.clone(),
119        manifest: Some(manifest.clone())
120      }]
121    }
122  }
123
124  /// Adds the data from the plugin manifest as a version to the index
125  pub fn add_version(&mut self, manifest: &PactPluginManifest, source: &ManifestSource) {
126    if let Some(version) = self.versions.iter_mut()
127      .find(|m| m.version == manifest.version) {
128      version.source = source.clone();
129      version.manifest = Some(manifest.clone());
130    } else {
131      self.versions.push(PluginVersion {
132        version: manifest.version.clone(),
133        source: source.clone(),
134        manifest: Some(manifest.clone())
135      });
136    }
137    self.update_latest_version();
138  }
139
140  fn update_latest_version(&mut self) {
141    let latest_version = self.versions.iter()
142      .max_by(|m1, m2| {
143        let a = Version::parse(&m1.version).unwrap_or_else(|_| Version::new(0, 0, 0));
144        let b = Version::parse(&m2.version).unwrap_or_else(|_| Version::new(0, 0, 0));
145        a.cmp(&b)
146      })
147      .map(|m| m.version.clone())
148      .unwrap_or_default();
149    self.latest_version = latest_version.clone();
150  }
151}
152
153impl Default for PluginRepositoryIndex {
154  fn default() -> Self {
155    #[cfg(feature = "datetime")]
156    {
157      let timestamp = Utc::now();
158      PluginRepositoryIndex {
159        index_version: 0,
160        format_version: 0,
161        timestamp,
162        entries: Default::default()
163      }
164    }
165    #[cfg(not(feature = "datetime"))]
166    {
167      use std::time::{SystemTime, UNIX_EPOCH};
168      let now = SystemTime::now().duration_since(UNIX_EPOCH)
169        .expect("system time before Unix epoch");
170      let naive = chrono::NaiveDateTime::from_timestamp_opt(now.as_secs() as i64, now.subsec_nanos())
171        .unwrap();
172      let timestamp = DateTime::from_utc(naive, Utc);
173      PluginRepositoryIndex {
174        index_version: 0,
175        format_version: 0,
176        timestamp,
177        entries: Default::default()
178      }
179    }
180  }
181}
182
183/// Retrieves the latest repository index, first from GitHub, and if not able to, then any locally
184/// cached index, otherwise defaults to the version compiled into the library.
185pub async fn fetch_repository_index(
186  http_client: &Client,
187  default_index: Option<&str>
188) -> anyhow::Result<PluginRepositoryIndex> {
189  fetch_index_from_github(http_client)
190    .await
191    .or_else(|err| {
192      warn!("Was not able to load index from GitHub - {}", err);
193      load_local_index()
194    })
195    .or_else(|err| {
196      warn!("Was not able to load local index, will use built in one - {}", err);
197      toml::from_str::<PluginRepositoryIndex>(default_index.unwrap_or(DEFAULT_INDEX))
198        .map_err(|err| anyhow!(err))
199    })
200}
201
202fn load_local_index() -> anyhow::Result<PluginRepositoryIndex> {
203  let plugin_dir = pact_plugin_dir()?;
204  if !plugin_dir.exists() {
205    return Err(anyhow!("Plugin directory does not exist"));
206  }
207
208  let repository_file = plugin_dir.join("repository.index");
209
210  let sha = calculate_sha(&repository_file)?;
211  let expected_sha = load_sha(&repository_file)?;
212  if sha != expected_sha {
213    return Err(anyhow!("Error: SHA256 digest does not match: expected {} but got {}", expected_sha, sha));
214  }
215
216  load_index_file(&repository_file)
217}
218
219async fn fetch_index_from_github(http_client: &Client) -> anyhow::Result<PluginRepositoryIndex> {
220  info!("Fetching index from github");
221  let index_contents = http_client.get("https://raw.githubusercontent.com/pact-foundation/pact-plugins/main/repository/repository.index")
222    .send()
223    .await?
224    .text()
225    .await?;
226
227  let index_sha = http_client.get("https://raw.githubusercontent.com/pact-foundation/pact-plugins/main/repository/repository.index.sha256")
228    .send()
229    .await?
230    .text()
231    .await?;
232  let mut hasher = Sha256::new();
233  hasher.update(index_contents.as_bytes());
234  let result = hasher.finalize();
235  let calculated = format!("{:x}", result);
236
237  if calculated != index_sha {
238    return Err(anyhow!("Error: SHA256 digest from GitHub does not match: expected {} but got {}", index_sha, calculated));
239  }
240
241  if let Err(err) = cache_index(&index_contents, &index_sha) {
242    warn!("Could not cache index to local file - {}", err);
243  }
244
245  Ok(toml::from_str(index_contents.as_str())?)
246}
247
248fn cache_index(index_contents: &String, sha: &String) -> anyhow::Result<()> {
249  let plugin_dir = pact_plugin_dir()?;
250  if !plugin_dir.exists() {
251    fs::create_dir_all(&plugin_dir)?;
252  }
253  let repository_file = plugin_dir.join("repository.index");
254  let mut f = File::create(repository_file)?;
255  f.write_all(index_contents.as_bytes())?;
256  let sha_file = plugin_dir.join("repository.index.sha256");
257  let mut f2 = File::create(sha_file)?;
258  f2.write_all(sha.as_bytes())?;
259  Ok(())
260}
261
262/// Loads the index file from the given path
263pub fn load_index_file(path: &PathBuf) -> anyhow::Result<PluginRepositoryIndex> {
264  debug!(?path, "Loading index file");
265  let f = File::open(path.as_path())?;
266  let mut reader = BufReader::new(f);
267  let mut buffer = String::new();
268  reader.read_to_string(&mut buffer)?;
269  let index: PluginRepositoryIndex = toml::from_str(buffer.as_str())?;
270  Ok(index)
271}
272
273/// Returns the SHA file for a given repository file.
274pub fn get_sha_file_for_repository_file(repository_file: &PathBuf) -> anyhow::Result<PathBuf> {
275  let filename_base = repository_file.file_name()
276    .ok_or_else(|| anyhow!("Could not get the filename for repository file '{}'", repository_file.to_string_lossy()))?
277    .to_string_lossy();
278  let sha_file = format!("{}.sha256", filename_base);
279  let file = repository_file.parent()
280    .ok_or_else(|| anyhow!("Could not get the parent path for repository file '{}'", repository_file.to_string_lossy()))?
281    .join(sha_file.as_str());
282  Ok(file)
283}
284
285/// Calculates the SHA hash for a given repository file path
286pub fn calculate_sha(repository_file: &PathBuf) -> anyhow::Result<String> {
287  let mut f = File::open(repository_file)?;
288  let mut hasher = Sha256::new();
289  let mut buffer = [0_u8; 256];
290  let mut done = false;
291
292  while !done {
293    let amount = f.read(&mut buffer)?;
294    if amount == 0 {
295      done = true;
296    } else if amount == 256 {
297      hasher.update(&buffer);
298    } else {
299      let b = &buffer[0..amount];
300      hasher.update(b);
301    }
302  }
303
304  let result = hasher.finalize();
305  let calculated = format!("{:x}", result);
306  Ok(calculated)
307}
308
309/// Loads the SHA for a given repository file
310pub fn load_sha(repository_file: &PathBuf) -> anyhow::Result<String> {
311  let sha_file = get_sha_file_for_repository_file(repository_file)?;
312  let mut f = File::open(sha_file)?;
313  let mut buffer = String::new();
314  f.read_to_string(&mut buffer)?;
315  Ok(buffer)
316}
317
318#[cfg(test)]
319mod tests {
320  use std::time::{SystemTime, UNIX_EPOCH};
321
322  use expectest::prelude::*;
323
324  use crate::repository::PluginRepositoryIndex;
325
326  #[test]
327  fn plugin_repository_index_default() {
328    let index = PluginRepositoryIndex::default();
329    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
330
331    expect!(index.index_version).to(be_equal_to(0));
332    expect!(index.format_version).to(be_equal_to(0));
333    expect!(index.entries.len()).to(be_equal_to(0));
334
335    let timestamp = index.timestamp.to_string();
336    expect!(timestamp).to_not(be_equal_to("1970-01-01 00:00:00 UTC"));
337
338    let ts = index.timestamp.naive_utc().and_utc().timestamp() as u64;
339    expect!(ts / 3600).to(be_equal_to(now / 3600));
340  }
341}