pact_plugin_driver/
repository.rs1use 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#[derive(Serialize, Deserialize, Debug, Clone)]
25pub struct PluginRepositoryIndex {
26 pub index_version: usize,
28
29 pub format_version: usize,
31
32 pub timestamp: DateTime<Utc>,
34
35 pub entries: HashMap<String, PluginEntry>
37}
38
39impl PluginRepositoryIndex {
40 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#[derive(Serialize, Deserialize, Debug, Clone)]
60pub struct PluginEntry {
61 pub name: String,
63 pub latest_version: String,
65 pub versions: Vec<PluginVersion>
67}
68
69#[derive(Serialize, Deserialize, Debug, Clone)]
71pub struct PluginVersion {
72 pub version: String,
74 pub source: ManifestSource,
76 pub manifest: Option<PactPluginManifest>
78}
79
80#[derive(Serialize, Deserialize, Debug, Clone)]
82#[serde(tag = "type", content = "value")]
83pub enum ManifestSource {
84 File(String),
86
87 GitHubRelease(String)
89}
90
91impl ManifestSource {
92 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 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 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 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
183pub 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
262pub 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
273pub 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
285pub 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
309pub 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}