1use std::io::IsTerminal;
6
7use quicknode_sdk::{
8 AdminConfig, CachedToken, HttpConfig, KvStoreConfig, QuicknodeSdk, RpcConfig, SdkFullConfig,
9 SqlConfig, StreamsConfig, WebhooksConfig,
10};
11
12use crate::config;
13use crate::errors::CliError;
14use crate::output::{Format, OutputCtx};
15
16#[derive(Debug, Clone, Default)]
18pub struct GlobalArgs {
19 pub api_key: Option<String>,
20 pub config_file: Option<std::path::PathBuf>,
23 pub format: Option<Format>,
27 pub wide: bool,
28 pub no_color: bool,
29 pub quiet: bool,
30 pub verbose: bool,
31 pub no_input: bool,
32 pub yes_count: u8,
33 pub retries: u32,
36 pub base_url: Option<String>,
37 pub base_prefix: Option<String>,
42}
43
44impl GlobalArgs {
45 pub fn resolve_format(&self, stdout_is_tty: bool) -> Format {
49 self.resolve_output(stdout_is_tty).0
50 }
51
52 pub fn resolve_output(&self, stdout_is_tty: bool) -> (Format, bool) {
60 let (cfg_format, cfg_wide) = self.load_output_config();
61 resolve_output_inner(self.format, self.wide, cfg_format, cfg_wide, stdout_is_tty)
62 }
63
64 pub fn resolve_config_path(&self) -> Option<std::path::PathBuf> {
66 self.config_file.clone().or_else(config::config_path)
67 }
68
69 fn load_output_config(&self) -> (Option<Format>, bool) {
70 let Some(p) = self.resolve_config_path() else {
71 return (None, false);
72 };
73 match config::load_from(&p) {
74 Ok(Some(cfg)) => (cfg.output.format, cfg.output.wide),
75 _ => (None, false),
76 }
77 }
78}
79
80fn resolve_output_inner(
84 flag_format: Option<Format>,
85 flag_wide: bool,
86 cfg_format: Option<Format>,
87 cfg_wide: bool,
88 stdout_is_tty: bool,
89) -> (Format, bool) {
90 let format = flag_format.or(cfg_format).unwrap_or(if stdout_is_tty {
91 Format::Table
92 } else {
93 Format::Json
94 });
95 let wide = flag_wide || cfg_wide;
96 (format, wide)
97}
98
99pub fn user_agent() -> String {
103 format!(
104 "quicknode-cli/{} ({}-{})",
105 env!("CARGO_PKG_VERSION"),
106 std::env::consts::OS,
107 std::env::consts::ARCH,
108 )
109}
110
111pub fn sdk_config(api_key: String) -> SdkFullConfig {
116 let mut full = SdkFullConfig::from_api_key(api_key);
117 let mut headers = std::collections::HashMap::new();
118 headers.insert("User-Agent".to_string(), user_agent());
119 full.http = Some(HttpConfig {
120 headers: Some(headers),
121 ..Default::default()
122 });
123 full
124}
125
126fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) {
130 full.admin = Some(AdminConfig {
131 base_url: Some(format!("{trimmed}/v0/")),
132 });
133 full.streams = Some(StreamsConfig {
134 base_url: Some(format!("{trimmed}/streams/rest/v1/")),
135 });
136 full.webhooks = Some(WebhooksConfig {
137 base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
138 });
139 full.kvstore = Some(KvStoreConfig {
140 base_url: Some(format!("{trimmed}/kv/rest/v1/")),
141 });
142 full.sql = Some(SqlConfig {
143 base_url: Some(format!("{trimmed}/sql/rest/v1/")),
144 });
145}
146
147pub fn sdk_config_with_base(
150 api_key: String,
151 base_url: Option<&str>,
152) -> Result<SdkFullConfig, CliError> {
153 let mut full = sdk_config(api_key);
154 if let Some(base) = base_url {
155 let trimmed = validate_base_url(base)?;
156 apply_base_url(&mut full, trimmed.as_str());
157 }
158 Ok(full)
159}
160
161pub struct Ctx {
162 pub sdk: QuicknodeSdk,
163 pub out: OutputCtx,
164 pub global: GlobalArgs,
165}
166
167impl Ctx {
168 pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
174 Self::build(global, None, None).map(|(ctx, _)| ctx)
175 }
176
177 pub fn from_global_with_rpc_seed(
183 global: GlobalArgs,
184 seed: Option<CachedToken>,
185 config_endpoint_url: Option<String>,
186 ) -> Result<(Self, String), CliError> {
187 Self::build(global, seed, config_endpoint_url)
188 }
189
190 fn build(
191 global: GlobalArgs,
192 rpc_seed: Option<CachedToken>,
193 rpc_endpoint_url: Option<String>,
194 ) -> Result<(Self, String), CliError> {
195 let config_path = global.resolve_config_path();
196 let stdout_is_tty = std::io::stdout().is_terminal();
197 let (format, wide) = global.resolve_output(stdout_is_tty);
198
199 let (api_key, _) = config::resolve_api_key(
200 global.api_key.as_deref(),
201 config_path.as_deref(),
202 false,
203 || unreachable!("prompt disabled for non-auth commands"),
204 )?;
205
206 let mut full = sdk_config(api_key.clone());
207
208 let rpc_endpoint_url = match rpc_endpoint_url {
214 Some(u) => Some(validate_endpoint_url(&u)?),
215 None => None,
216 };
217 if rpc_seed.is_some() || rpc_endpoint_url.is_some() {
218 full.rpc = Some(RpcConfig {
219 seed: rpc_seed,
220 endpoint_url: rpc_endpoint_url,
221 ..Default::default()
222 });
223 }
224
225 if global.base_prefix.is_some() && global.base_url.is_none() {
229 return Err(CliError::Arg(
230 "--base-prefix requires --base-url".to_string(),
231 ));
232 }
233
234 if let Some(base) = &global.base_url {
240 let host = validate_base_url(base)?;
241 let prefix = match &global.base_prefix {
242 Some(p) => validate_base_prefix(p)?,
243 None => String::new(),
244 };
245 let root = format!("{host}{prefix}");
246 apply_base_url(&mut full, &root);
247 }
248
249 let sdk = QuicknodeSdk::new(&full)?;
250 let out = OutputCtx::detect_with(
251 format,
252 global.no_color,
253 global.quiet,
254 global.verbose,
255 wide,
256 stdout_is_tty,
257 std::env::var_os("NO_COLOR"),
258 std::env::var("TERM").ok(),
259 );
260
261 Ok((Self { sdk, out, global }, api_key))
262 }
263}
264
265fn validate_base_url(base: &str) -> Result<String, CliError> {
270 let parsed = url::Url::parse(base)
271 .map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
272 match parsed.scheme() {
273 "http" | "https" => {}
274 other => {
275 return Err(CliError::Arg(format!(
276 "--base-url scheme '{other}' is not allowed; use http or https"
277 )))
278 }
279 }
280 if !parsed.username().is_empty() || parsed.password().is_some() {
281 return Err(CliError::Arg(
282 "--base-url must not contain userinfo (username/password)".into(),
283 ));
284 }
285 if parsed.query().is_some() || parsed.fragment().is_some() {
286 return Err(CliError::Arg(
287 "--base-url must not contain a query string or fragment".into(),
288 ));
289 }
290 if !matches!(parsed.path(), "" | "/") {
291 return Err(CliError::Arg("--base-url must not contain a path".into()));
292 }
293 Ok(base.trim_end_matches('/').to_string())
294}
295
296pub(crate) fn validate_endpoint_url(url: &str) -> Result<String, CliError> {
302 let parsed = url::Url::parse(url)
303 .map_err(|_| CliError::Arg(format!("--endpoint-url '{url}' is not a valid URL")))?;
304 match parsed.scheme() {
305 "http" | "https" => Ok(url.to_string()),
306 other => Err(CliError::Arg(format!(
307 "--endpoint-url scheme '{other}' is not allowed; use http or https"
308 ))),
309 }
310}
311
312fn validate_base_prefix(prefix: &str) -> Result<String, CliError> {
317 let trimmed = prefix.trim();
318 if trimmed.is_empty() {
319 return Ok(String::new());
320 }
321 if trimmed.contains("//") {
322 return Err(CliError::Arg(
323 "--base-prefix must be a path, not a URL (no '//')".into(),
324 ));
325 }
326 if trimmed.contains(['?', '#', '\\']) {
327 return Err(CliError::Arg(
328 "--base-prefix must not contain a query string, fragment, or backslash".into(),
329 ));
330 }
331 let inner = trimmed.trim_matches('/');
332 if inner.is_empty() {
333 return Ok(String::new());
335 }
336 let normalized = format!("/{inner}");
337 if normalized.split('/').any(|seg| matches!(seg, "." | "..")) {
338 return Err(CliError::Arg(
339 "--base-prefix must not contain '.' or '..' path segments".into(),
340 ));
341 }
342 Ok(normalized)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn flag_format_wins_over_config_and_tty_default() {
351 let (f, _) =
352 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
353 assert_eq!(f, Format::Json);
354 let (f, _) =
355 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
356 assert_eq!(f, Format::Json);
357 }
358
359 #[test]
360 fn config_format_wins_over_tty_default() {
361 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
362 assert_eq!(f, Format::Yaml);
363 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
364 assert_eq!(f, Format::Yaml);
365 }
366
367 #[test]
368 fn default_is_table_when_stdout_is_a_tty() {
369 let (f, _) = resolve_output_inner(None, false, None, false, true);
370 assert_eq!(f, Format::Table);
371 }
372
373 #[test]
374 fn default_is_json_when_stdout_is_not_a_tty() {
375 let (f, _) = resolve_output_inner(None, false, None, false, false);
376 assert_eq!(f, Format::Json);
377 }
378
379 #[test]
380 fn config_toon_overrides_non_tty_default() {
381 let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
382 assert_eq!(f, Format::Toon);
383 }
384
385 #[test]
386 fn wide_is_additive_between_flag_and_config() {
387 let (_, w) = resolve_output_inner(None, true, None, false, true);
389 assert!(w);
390 let (_, w) = resolve_output_inner(None, false, None, true, true);
392 assert!(w);
393 let (_, w) = resolve_output_inner(None, true, None, true, true);
395 assert!(w);
396 let (_, w) = resolve_output_inner(None, false, None, false, true);
398 assert!(!w);
399 }
400
401 #[test]
402 fn base_url_accepts_plain_http_and_https() {
403 assert_eq!(
404 validate_base_url("https://api.quicknode.com").unwrap(),
405 "https://api.quicknode.com"
406 );
407 assert_eq!(
408 validate_base_url("http://127.0.0.1:8080/").unwrap(),
409 "http://127.0.0.1:8080"
410 );
411 }
412
413 #[test]
414 fn base_url_rejects_non_http_schemes() {
415 for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
416 assert!(validate_base_url(bad).is_err(), "should reject {bad}");
417 }
418 }
419
420 #[test]
421 fn base_url_rejects_userinfo() {
422 assert!(validate_base_url("https://user:pass@evil/").is_err());
423 assert!(validate_base_url("https://user@evil/").is_err());
424 }
425
426 #[test]
427 fn base_url_rejects_path_query_fragment() {
428 assert!(validate_base_url("https://x/extra/path").is_err());
429 assert!(validate_base_url("https://x/?q=1").is_err());
430 assert!(validate_base_url("https://x/#frag").is_err());
431 }
432
433 #[test]
434 fn base_url_rejects_garbage() {
435 assert!(validate_base_url("not a url").is_err());
436 assert!(validate_base_url("").is_err());
437 }
438
439 #[test]
440 fn endpoint_url_allows_http_https_with_path() {
441 assert_eq!(
442 validate_endpoint_url("https://my-endpoint.example/rpc").unwrap(),
443 "https://my-endpoint.example/rpc"
444 );
445 assert_eq!(
446 validate_endpoint_url("http://127.0.0.1:8080/some/path?x=1").unwrap(),
447 "http://127.0.0.1:8080/some/path?x=1"
448 );
449 }
450
451 #[test]
452 fn endpoint_url_rejects_non_http_schemes_and_garbage() {
453 for bad in ["ftp://x/rpc", "file:///etc/passwd", "not a url", ""] {
454 assert!(validate_endpoint_url(bad).is_err(), "should reject {bad}");
455 }
456 }
457
458 #[test]
459 fn base_prefix_normalizes_slashes() {
460 assert_eq!(
461 validate_base_prefix("/console-api").unwrap(),
462 "/console-api"
463 );
464 assert_eq!(validate_base_prefix("console-api").unwrap(), "/console-api");
465 assert_eq!(
466 validate_base_prefix("/console-api/").unwrap(),
467 "/console-api"
468 );
469 assert_eq!(validate_base_prefix("/a/b").unwrap(), "/a/b");
470 }
471
472 #[test]
473 fn base_prefix_empty_is_empty() {
474 assert_eq!(validate_base_prefix("").unwrap(), "");
475 assert_eq!(validate_base_prefix(" ").unwrap(), "");
476 assert_eq!(validate_base_prefix("/").unwrap(), "");
477 }
478
479 #[test]
480 fn base_prefix_rejects_url_like_and_traversal() {
481 assert!(validate_base_prefix("//evil.com").is_err());
482 assert!(validate_base_prefix("http://evil.com").is_err());
483 assert!(validate_base_prefix("/a?b=1").is_err());
484 assert!(validate_base_prefix("/a#frag").is_err());
485 assert!(validate_base_prefix("/../etc").is_err());
486 assert!(validate_base_prefix("/a/../b").is_err());
487 }
488
489 #[test]
490 fn user_agent_identifies_the_cli() {
491 let ua = user_agent();
492 assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
493 assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
494 }
495
496 #[test]
497 fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
498 let cfg = sdk_config("k".to_string());
499 let http = cfg.http.expect("http config should be set");
500 assert_eq!(
501 http.headers.as_ref().and_then(|h| h.get("User-Agent")),
502 Some(&user_agent())
503 );
504 assert_eq!(http.timeout_secs, None);
506 assert_eq!(http.pool_max_idle_per_host, None);
507 }
508}