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 apply_user_agent(&mut full);
118 full
119}
120
121fn apply_user_agent(full: &mut SdkFullConfig) {
125 let mut headers = std::collections::HashMap::new();
126 headers.insert("User-Agent".to_string(), user_agent());
127 full.http = Some(HttpConfig {
128 headers: Some(headers),
129 ..Default::default()
130 });
131}
132
133fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) {
137 full.admin = Some(AdminConfig {
138 base_url: Some(format!("{trimmed}/v0/")),
139 });
140 full.streams = Some(StreamsConfig {
141 base_url: Some(format!("{trimmed}/streams/rest/v1/")),
142 });
143 full.webhooks = Some(WebhooksConfig {
144 base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
145 });
146 full.kvstore = Some(KvStoreConfig {
147 base_url: Some(format!("{trimmed}/kv/rest/v1/")),
148 });
149 full.sql = Some(SqlConfig {
150 base_url: Some(format!("{trimmed}/sql/rest/v1/")),
151 });
152}
153
154pub fn sdk_config_with_base(
157 api_key: String,
158 base_url: Option<&str>,
159) -> Result<SdkFullConfig, CliError> {
160 let mut full = sdk_config(api_key);
161 if let Some(base) = base_url {
162 let trimmed = validate_base_url(base)?;
163 apply_base_url(&mut full, trimmed.as_str());
164 }
165 Ok(full)
166}
167
168pub struct Ctx {
169 pub sdk: QuicknodeSdk,
170 pub out: OutputCtx,
171 pub global: GlobalArgs,
172}
173
174impl Ctx {
175 pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
181 Self::build(global, None, None).map(|(ctx, _)| ctx)
182 }
183
184 pub fn from_global_with_rpc_seed(
190 global: GlobalArgs,
191 seed: Option<CachedToken>,
192 config_endpoint_url: Option<String>,
193 ) -> Result<(Self, String), CliError> {
194 Self::build(global, seed, config_endpoint_url)
195 }
196
197 pub fn from_global_keyless_payment(
199 global: GlobalArgs,
200 payment: quicknode_sdk::PaymentConfig,
201 ) -> Result<Self, CliError> {
202 let stdout_is_tty = std::io::stdout().is_terminal();
203 let (format, wide) = global.resolve_output(stdout_is_tty);
204
205 let mut full = SdkFullConfig::keyless();
206 apply_user_agent(&mut full);
207 full.rpc = Some(RpcConfig {
208 payment: Some(payment),
209 ..Default::default()
210 });
211
212 let sdk = QuicknodeSdk::new(&full)?;
213 let out = OutputCtx::detect_with(
214 format,
215 global.no_color,
216 global.quiet,
217 global.verbose,
218 wide,
219 stdout_is_tty,
220 std::env::var_os("NO_COLOR"),
221 std::env::var("TERM").ok(),
222 );
223
224 Ok(Self { sdk, out, global })
225 }
226
227 pub fn from_global_keyless(global: GlobalArgs) -> Result<Self, CliError> {
229 let stdout_is_tty = std::io::stdout().is_terminal();
230 let (format, wide) = global.resolve_output(stdout_is_tty);
231
232 let mut full = SdkFullConfig::keyless();
233 apply_user_agent(&mut full);
234
235 let sdk = QuicknodeSdk::new(&full)?;
236 let out = OutputCtx::detect_with(
237 format,
238 global.no_color,
239 global.quiet,
240 global.verbose,
241 wide,
242 stdout_is_tty,
243 std::env::var_os("NO_COLOR"),
244 std::env::var("TERM").ok(),
245 );
246
247 Ok(Self { sdk, out, global })
248 }
249
250 fn build(
251 global: GlobalArgs,
252 rpc_seed: Option<CachedToken>,
253 rpc_endpoint_url: Option<String>,
254 ) -> Result<(Self, String), CliError> {
255 let config_path = global.resolve_config_path();
256 let stdout_is_tty = std::io::stdout().is_terminal();
257 let (format, wide) = global.resolve_output(stdout_is_tty);
258
259 let (api_key, _) = config::resolve_api_key(
260 global.api_key.as_deref(),
261 config_path.as_deref(),
262 false,
263 || unreachable!("prompt disabled for non-auth commands"),
264 )?;
265
266 let mut full = sdk_config(api_key.clone());
267
268 let rpc_endpoint_url = match rpc_endpoint_url {
270 Some(u) => Some(validate_endpoint_url(&u)?),
271 None => None,
272 };
273 if rpc_seed.is_some() || rpc_endpoint_url.is_some() {
274 full.rpc = Some(RpcConfig {
275 seed: rpc_seed,
276 endpoint_url: rpc_endpoint_url,
277 ..Default::default()
278 });
279 }
280
281 if global.base_prefix.is_some() && global.base_url.is_none() {
282 return Err(CliError::Arg(
283 "--base-prefix requires --base-url".to_string(),
284 ));
285 }
286
287 if let Some(base) = &global.base_url {
288 let host = validate_base_url(base)?;
289 let prefix = match &global.base_prefix {
290 Some(p) => validate_base_prefix(p)?,
291 None => String::new(),
292 };
293 let root = format!("{host}{prefix}");
294 apply_base_url(&mut full, &root);
295 }
296
297 let sdk = QuicknodeSdk::new(&full)?;
298 let out = OutputCtx::detect_with(
299 format,
300 global.no_color,
301 global.quiet,
302 global.verbose,
303 wide,
304 stdout_is_tty,
305 std::env::var_os("NO_COLOR"),
306 std::env::var("TERM").ok(),
307 );
308
309 Ok((Self { sdk, out, global }, api_key))
310 }
311}
312
313fn validate_base_url(base: &str) -> Result<String, CliError> {
318 let parsed = url::Url::parse(base)
319 .map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
320 match parsed.scheme() {
321 "http" | "https" => {}
322 other => {
323 return Err(CliError::Arg(format!(
324 "--base-url scheme '{other}' is not allowed; use http or https"
325 )))
326 }
327 }
328 if !parsed.username().is_empty() || parsed.password().is_some() {
329 return Err(CliError::Arg(
330 "--base-url must not contain userinfo (username/password)".into(),
331 ));
332 }
333 if parsed.query().is_some() || parsed.fragment().is_some() {
334 return Err(CliError::Arg(
335 "--base-url must not contain a query string or fragment".into(),
336 ));
337 }
338 if !matches!(parsed.path(), "" | "/") {
339 return Err(CliError::Arg("--base-url must not contain a path".into()));
340 }
341 Ok(base.trim_end_matches('/').to_string())
342}
343
344pub(crate) fn validate_endpoint_url(url: &str) -> Result<String, CliError> {
350 let parsed = url::Url::parse(url)
351 .map_err(|_| CliError::Arg(format!("--endpoint-url '{url}' is not a valid URL")))?;
352 match parsed.scheme() {
353 "http" | "https" => Ok(url.to_string()),
354 other => Err(CliError::Arg(format!(
355 "--endpoint-url scheme '{other}' is not allowed; use http or https"
356 ))),
357 }
358}
359
360fn validate_base_prefix(prefix: &str) -> Result<String, CliError> {
365 let trimmed = prefix.trim();
366 if trimmed.is_empty() {
367 return Ok(String::new());
368 }
369 if trimmed.contains("//") {
370 return Err(CliError::Arg(
371 "--base-prefix must be a path, not a URL (no '//')".into(),
372 ));
373 }
374 if trimmed.contains(['?', '#', '\\']) {
375 return Err(CliError::Arg(
376 "--base-prefix must not contain a query string, fragment, or backslash".into(),
377 ));
378 }
379 let inner = trimmed.trim_matches('/');
380 if inner.is_empty() {
381 return Ok(String::new());
382 }
383 let normalized = format!("/{inner}");
384 if normalized.split('/').any(|seg| matches!(seg, "." | "..")) {
385 return Err(CliError::Arg(
386 "--base-prefix must not contain '.' or '..' path segments".into(),
387 ));
388 }
389 Ok(normalized)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn flag_format_wins_over_config_and_tty_default() {
398 let (f, _) =
399 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
400 assert_eq!(f, Format::Json);
401 let (f, _) =
402 resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
403 assert_eq!(f, Format::Json);
404 }
405
406 #[test]
407 fn config_format_wins_over_tty_default() {
408 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
409 assert_eq!(f, Format::Yaml);
410 let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
411 assert_eq!(f, Format::Yaml);
412 }
413
414 #[test]
415 fn default_is_table_when_stdout_is_a_tty() {
416 let (f, _) = resolve_output_inner(None, false, None, false, true);
417 assert_eq!(f, Format::Table);
418 }
419
420 #[test]
421 fn default_is_json_when_stdout_is_not_a_tty() {
422 let (f, _) = resolve_output_inner(None, false, None, false, false);
423 assert_eq!(f, Format::Json);
424 }
425
426 #[test]
427 fn config_toon_overrides_non_tty_default() {
428 let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
429 assert_eq!(f, Format::Toon);
430 }
431
432 #[test]
433 fn wide_is_additive_between_flag_and_config() {
434 let (_, w) = resolve_output_inner(None, true, None, false, true);
436 assert!(w);
437 let (_, w) = resolve_output_inner(None, false, None, true, true);
439 assert!(w);
440 let (_, w) = resolve_output_inner(None, true, None, true, true);
442 assert!(w);
443 let (_, w) = resolve_output_inner(None, false, None, false, true);
445 assert!(!w);
446 }
447
448 #[test]
449 fn base_url_accepts_plain_http_and_https() {
450 assert_eq!(
451 validate_base_url("https://api.quicknode.com").unwrap(),
452 "https://api.quicknode.com"
453 );
454 assert_eq!(
455 validate_base_url("http://127.0.0.1:8080/").unwrap(),
456 "http://127.0.0.1:8080"
457 );
458 }
459
460 #[test]
461 fn base_url_rejects_non_http_schemes() {
462 for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
463 assert!(validate_base_url(bad).is_err(), "should reject {bad}");
464 }
465 }
466
467 #[test]
468 fn base_url_rejects_userinfo() {
469 assert!(validate_base_url("https://user:pass@evil/").is_err());
470 assert!(validate_base_url("https://user@evil/").is_err());
471 }
472
473 #[test]
474 fn base_url_rejects_path_query_fragment() {
475 assert!(validate_base_url("https://x/extra/path").is_err());
476 assert!(validate_base_url("https://x/?q=1").is_err());
477 assert!(validate_base_url("https://x/#frag").is_err());
478 }
479
480 #[test]
481 fn base_url_rejects_garbage() {
482 assert!(validate_base_url("not a url").is_err());
483 assert!(validate_base_url("").is_err());
484 }
485
486 #[test]
487 fn endpoint_url_allows_http_https_with_path() {
488 assert_eq!(
489 validate_endpoint_url("https://my-endpoint.example/rpc").unwrap(),
490 "https://my-endpoint.example/rpc"
491 );
492 assert_eq!(
493 validate_endpoint_url("http://127.0.0.1:8080/some/path?x=1").unwrap(),
494 "http://127.0.0.1:8080/some/path?x=1"
495 );
496 }
497
498 #[test]
499 fn endpoint_url_rejects_non_http_schemes_and_garbage() {
500 for bad in ["ftp://x/rpc", "file:///etc/passwd", "not a url", ""] {
501 assert!(validate_endpoint_url(bad).is_err(), "should reject {bad}");
502 }
503 }
504
505 #[test]
506 fn base_prefix_normalizes_slashes() {
507 assert_eq!(
508 validate_base_prefix("/console-api").unwrap(),
509 "/console-api"
510 );
511 assert_eq!(validate_base_prefix("console-api").unwrap(), "/console-api");
512 assert_eq!(
513 validate_base_prefix("/console-api/").unwrap(),
514 "/console-api"
515 );
516 assert_eq!(validate_base_prefix("/a/b").unwrap(), "/a/b");
517 }
518
519 #[test]
520 fn base_prefix_empty_is_empty() {
521 assert_eq!(validate_base_prefix("").unwrap(), "");
522 assert_eq!(validate_base_prefix(" ").unwrap(), "");
523 assert_eq!(validate_base_prefix("/").unwrap(), "");
524 }
525
526 #[test]
527 fn base_prefix_rejects_url_like_and_traversal() {
528 assert!(validate_base_prefix("//evil.com").is_err());
529 assert!(validate_base_prefix("http://evil.com").is_err());
530 assert!(validate_base_prefix("/a?b=1").is_err());
531 assert!(validate_base_prefix("/a#frag").is_err());
532 assert!(validate_base_prefix("/../etc").is_err());
533 assert!(validate_base_prefix("/a/../b").is_err());
534 }
535
536 #[test]
537 fn user_agent_identifies_the_cli() {
538 let ua = user_agent();
539 assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
540 assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
541 }
542
543 #[test]
544 fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
545 let cfg = sdk_config("k".to_string());
546 let http = cfg.http.expect("http config should be set");
547 assert_eq!(
548 http.headers.as_ref().and_then(|h| h.get("User-Agent")),
549 Some(&user_agent())
550 );
551 assert_eq!(http.timeout_secs, None);
552 assert_eq!(http.pool_max_idle_per_host, None);
553 }
554}