1use crate::{
2 consts::{
3 CHAIN_CONFIG_REPO_NAME, CONFIG_FOLDER, IGNITION_CONFIG_FOLDER_NAME,
4 LOCAL_CONFIG_FOLDER_NAME, TESTNET_CONFIG_FOLDER_NAME,
5 },
6 util::ask_user_yes_no_question,
7};
8use anyhow::{bail, Result};
9use forc_tracing::{println_action_green, println_warning};
10use forc_util::user_forc_directory;
11use serde::{Deserialize, Serialize};
12use sha1::{Digest, Sha1};
13use std::{
14 collections::{HashMap, HashSet},
15 fmt::Display,
16 fs,
17 path::PathBuf,
18};
19
20#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
22pub enum ChainConfig {
23 Local,
24 Testnet,
25 Ignition,
26}
27
28impl Display for ChainConfig {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 ChainConfig::Local => write!(f, "local"),
32 ChainConfig::Testnet => write!(f, "testnet"),
33 ChainConfig::Ignition => write!(f, "ignition"),
34 }
35 }
36}
37
38impl From<ChainConfig> for PathBuf {
39 fn from(value: ChainConfig) -> Self {
40 let folder_name = match value {
41 ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
42 ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
43 ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
44 };
45 user_forc_directory().join(CONFIG_FOLDER).join(folder_name)
46 }
47}
48
49#[derive(Serialize, Deserialize, Debug)]
52struct GithubContentDetails {
53 name: String,
54 sha: String,
55 download_url: Option<String>,
56 #[serde(rename = "type")]
57 content_type: String,
58}
59
60pub struct ConfigFetcher {
70 client: reqwest::Client,
71 base_url: String,
72 config_vault: PathBuf,
73}
74
75impl Default for ConfigFetcher {
76 fn default() -> Self {
79 Self {
80 client: reqwest::Client::new(),
81 base_url: "https://api.github.com".to_string(),
82 config_vault: user_forc_directory().join(CONFIG_FOLDER),
83 }
84 }
85}
86
87impl ConfigFetcher {
88 #[cfg(test)]
89 pub fn with_base_url(base_url: String) -> Self {
91 Self {
92 client: reqwest::Client::new(),
93 base_url,
94 config_vault: user_forc_directory().join(CONFIG_FOLDER),
95 }
96 }
97
98 #[cfg(test)]
99 pub fn with_test_config(base_url: String, config_vault: PathBuf) -> Self {
100 Self {
101 client: reqwest::Client::new(),
102 base_url,
103 config_vault,
104 }
105 }
106
107 fn get_base_url(&self) -> &str {
108 &self.base_url
109 }
110
111 fn build_api_endpoint(&self, folder_name: &str) -> String {
112 format!(
113 "{}/repos/FuelLabs/{}/contents/{}",
114 self.get_base_url(),
115 CHAIN_CONFIG_REPO_NAME,
116 folder_name,
117 )
118 }
119
120 async fn check_github_files(
124 &self,
125 conf: &ChainConfig,
126 ) -> anyhow::Result<Vec<GithubContentDetails>> {
127 let folder_name = match conf {
128 ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
129 ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
130 ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
131 };
132 let api_endpoint = self.build_api_endpoint(folder_name);
133
134 let response = self
135 .client
136 .get(&api_endpoint)
137 .header("User-Agent", "forc-node")
138 .send()
139 .await?;
140
141 if !response.status().is_success() {
142 bail!("failed to fetch updates from github")
143 }
144
145 let contents: Vec<GithubContentDetails> = response.json().await?;
146 Ok(contents)
147 }
148
149 fn check_local_files(&self, conf: &ChainConfig) -> Result<Option<HashMap<String, String>>> {
153 let folder_name = match conf {
154 ChainConfig::Local => bail!("Local configuration should not be checked"),
155 ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
156 ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
157 };
158
159 let folder_path = self.config_vault.join(folder_name);
160
161 if !folder_path.exists() {
162 return Ok(None);
163 }
164
165 let mut files = HashMap::new();
166 for entry in std::fs::read_dir(&folder_path)? {
167 let entry = entry?;
168 if entry.path().is_file() {
169 let content = std::fs::read(entry.path())?;
170 let mut hasher = Sha1::new();
172 hasher.update(b"blob ");
173 hasher.update(content.len().to_string().as_bytes());
174 hasher.update([0]);
175 hasher.update(&content);
176 let sha = format!("{:x}", hasher.finalize());
177
178 let name = entry.file_name().into_string().unwrap();
179 files.insert(name, sha);
180 }
181 }
182
183 Ok(Some(files))
184 }
185
186 pub async fn check_fetch_required(&self, conf: &ChainConfig) -> anyhow::Result<bool> {
190 if *conf == ChainConfig::Local {
191 return Ok(false);
192 }
193
194 let local_files = match self.check_local_files(conf)? {
195 Some(files) => files,
196 None => return Ok(true), };
198
199 let github_files = self.check_github_files(conf).await?;
200
201 for github_file in &github_files {
203 if github_file.content_type == "file" {
204 match local_files.get(&github_file.name) {
205 Some(local_sha) if local_sha == &github_file.sha => continue,
206 _ => return Ok(true), }
208 }
209 }
210
211 let github_filenames: HashSet<_> = github_files
213 .iter()
214 .filter(|f| f.content_type == "file")
215 .map(|f| &f.name)
216 .collect();
217
218 let local_filenames: HashSet<_> = local_files.keys().collect();
219
220 if local_filenames != github_filenames {
221 return Ok(true);
222 }
223
224 Ok(false)
225 }
226
227 pub async fn download_config(&self, conf: &ChainConfig) -> anyhow::Result<()> {
230 let folder_name = match conf {
231 ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
232 ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
233 ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
234 };
235
236 let api_endpoint = format!(
237 "https://api.github.com/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{folder_name}",
238 );
239
240 let contents = self.fetch_folder_contents(&api_endpoint).await?;
241
242 let config_dir = user_forc_directory().join(CONFIG_FOLDER);
244 let target_dir = config_dir.join(folder_name);
245 fs::create_dir_all(&target_dir)?;
246
247 for item in contents {
249 if item.content_type == "file" {
250 if let Some(download_url) = item.download_url {
251 let file_path = target_dir.join(&item.name);
252
253 let response = self.client.get(&download_url).send().await?;
254
255 if !response.status().is_success() {
256 bail!("Failed to download file: {}", item.name);
257 }
258
259 let content = response.bytes().await?;
260 fs::write(file_path, content)?;
261 }
262 }
263 }
264
265 Ok(())
266 }
267
268 async fn fetch_folder_contents(&self, url: &str) -> anyhow::Result<Vec<GithubContentDetails>> {
270 let response = self
271 .client
272 .get(url)
273 .header("User-Agent", "forc-node")
274 .send()
275 .await?;
276
277 if !response.status().is_success() {
278 bail!("failed to fetch contents from github");
279 }
280
281 Ok(response.json().await?)
282 }
283}
284
285async fn validate_local_chainconfig(fetcher: &ConfigFetcher) -> anyhow::Result<()> {
289 let user_conf_dir = user_forc_directory().join(CONFIG_FOLDER);
290 let local_conf_dir = user_conf_dir.join(LOCAL_CONFIG_FOLDER_NAME);
291 if !local_conf_dir.exists() {
292 println_warning(&format!(
293 "Local node configuration files are missing at {}",
294 local_conf_dir.display()
295 ));
296 let update = ask_user_yes_no_question("Would you like to download network configuration?")?;
298 if update {
299 fetcher.download_config(&ChainConfig::Local).await?;
300 } else {
301 bail!(
302 "Missing local network configuration, create one at {}",
303 local_conf_dir.display()
304 );
305 }
306 }
307 Ok(())
308}
309
310async fn validate_remote_chainconfig(
317 fetcher: &ConfigFetcher,
318 conf: &ChainConfig,
319) -> anyhow::Result<()> {
320 println_action_green("Checking", "for network configuration updates.");
322
323 if fetcher.check_fetch_required(conf).await? {
324 println_warning(&format!(
325 "A network configuration update detected for {conf}, this might create problems while syncing with rest of the network"
326 ));
327 let update = ask_user_yes_no_question("Would you like to update network configuration?")?;
329 if update {
330 println_action_green("Updating", &format!("configuration files for {conf}",));
331 fetcher.download_config(conf).await?;
332 println_action_green(
333 "Finished",
334 &format!("updating configuration files for {conf}",),
335 );
336 }
337 } else {
338 println_action_green(&format!("{conf}"), "is up-to-date.");
339 }
340 Ok(())
341}
342
343pub async fn check_and_update_chain_config(conf: ChainConfig) -> anyhow::Result<()> {
347 let fetcher = ConfigFetcher::default();
348 match conf {
349 ChainConfig::Local => validate_local_chainconfig(&fetcher).await?,
350 remote_config => validate_remote_chainconfig(&fetcher, &remote_config).await?,
351 }
352 Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use tempfile::TempDir;
359 use wiremock::{
360 matchers::{method, path},
361 Mock, MockServer, ResponseTemplate,
362 };
363
364 fn create_github_response(files: &[(&str, &str)]) -> Vec<GithubContentDetails> {
366 files
367 .iter()
368 .map(|(name, content)| {
369 let mut hasher = Sha1::new();
370 hasher.update(b"blob ");
371 hasher.update(content.len().to_string().as_bytes());
372 hasher.update([0]);
373 hasher.update(content.as_bytes());
374 let sha = format!("{:x}", hasher.finalize());
375
376 GithubContentDetails {
377 name: name.to_string(),
378 sha,
379 download_url: Some(format!("https://raw.githubusercontent.com/test/{name}")),
380 content_type: "file".to_string(),
381 }
382 })
383 .collect()
384 }
385
386 #[tokio::test]
387 async fn test_fetch_not_required_when_files_match() {
388 let mock_server = MockServer::start().await;
389 let test_files = [
390 ("config.json", "test config content"),
391 ("metadata.json", "test metadata content"),
392 ];
393
394 let test_dir = TempDir::new().unwrap();
396 let config_path = test_dir.path().to_path_buf();
397 let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
398 fs::create_dir_all(&test_folder).unwrap();
399
400 for (name, content) in &test_files {
401 fs::write(test_folder.join(name), content).unwrap();
402 }
403
404 let github_response = create_github_response(&test_files);
406 Mock::given(method("GET"))
407 .and(path(format!(
408 "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
409 )))
410 .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
411 .mount(&mock_server)
412 .await;
413
414 let fetcher = ConfigFetcher::with_test_config(mock_server.uri(), config_path);
415
416 let needs_fetch = fetcher
417 .check_fetch_required(&ChainConfig::Testnet)
418 .await
419 .unwrap();
420
421 assert!(
422 !needs_fetch,
423 "Fetch should not be required when files match"
424 );
425 }
426
427 #[tokio::test]
428 async fn test_fetch_required_when_files_differ() {
429 let mock_server = MockServer::start().await;
430
431 let test_dir = TempDir::new().unwrap();
433 let config_path = test_dir.path().join("fuel").join("configs");
434 let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
435 fs::create_dir_all(&test_folder).unwrap();
436
437 let local_files = [
438 ("config.json", "old config content"),
439 ("metadata.json", "old metadata content"),
440 ];
441
442 for (name, content) in &local_files {
443 fs::write(test_folder.join(name), content).unwrap();
444 }
445
446 let github_files = [
448 ("config.json", "new config content"),
449 ("metadata.json", "new metadata content"),
450 ];
451 let github_response = create_github_response(&github_files);
452
453 Mock::given(method("GET"))
454 .and(path(format!(
455 "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
456 )))
457 .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
458 .mount(&mock_server)
459 .await;
460
461 let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
462
463 let needs_fetch = fetcher
464 .check_fetch_required(&ChainConfig::Testnet)
465 .await
466 .unwrap();
467
468 assert!(needs_fetch, "Fetch should be required when files differ");
469 }
470
471 #[tokio::test]
472 async fn test_fetch_required_when_files_missing() {
473 let mock_server = MockServer::start().await;
474
475 let test_dir = TempDir::new().unwrap();
477 let config_path = test_dir.path().join("fuel").join("configs");
478 let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
479 fs::create_dir_all(&test_folder).unwrap();
480
481 let local_files = [("config.json", "test config content")];
482
483 for (name, content) in &local_files {
484 fs::write(test_folder.join(name), content).unwrap();
485 }
486
487 let github_files = [
489 ("config.json", "test config content"),
490 ("metadata.json", "test metadata content"),
491 ];
492 let github_response = create_github_response(&github_files);
493
494 Mock::given(method("GET"))
495 .and(path(format!(
496 "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
497 )))
498 .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
499 .mount(&mock_server)
500 .await;
501
502 let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
503
504 let needs_fetch = fetcher
505 .check_fetch_required(&ChainConfig::Testnet)
506 .await
507 .unwrap();
508
509 assert!(
510 needs_fetch,
511 "Fetch should be required when files are missing"
512 );
513 }
514
515 #[tokio::test]
516 async fn test_local_configuration_never_needs_fetch() {
517 let fetcher = ConfigFetcher::default();
518 let needs_fetch = fetcher
519 .check_fetch_required(&ChainConfig::Local)
520 .await
521 .unwrap();
522
523 assert!(!needs_fetch, "Local configuration should never need fetch");
524 }
525
526 #[tokio::test]
527 async fn test_fetch_required_when_extra_local_files() {
528 let mock_server = MockServer::start().await;
529
530 let test_dir = TempDir::new().unwrap();
532 let config_path = test_dir.path().join("fuel").join("configs");
533 let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
534 fs::create_dir_all(&test_folder).unwrap();
535
536 let local_files = [
537 ("config.json", "test config content"),
538 ("metadata.json", "test metadata content"),
539 ("extra.json", "extra file content"),
540 ];
541
542 for (name, content) in &local_files {
543 fs::write(test_folder.join(name), content).unwrap();
544 }
545
546 let github_files = [
548 ("config.json", "test config content"),
549 ("metadata.json", "test metadata content"),
550 ];
551 let github_response = create_github_response(&github_files);
552
553 Mock::given(method("GET"))
554 .and(path(format!(
555 "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
556 )))
557 .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
558 .mount(&mock_server)
559 .await;
560
561 let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
562
563 let needs_fetch = fetcher
564 .check_fetch_required(&ChainConfig::Testnet)
565 .await
566 .unwrap();
567
568 assert!(
569 needs_fetch,
570 "Fetch should be required when there are extra local files"
571 );
572 }
573}