1use std::io::{self, IsTerminal, Read, Write};
21use std::path::Path;
22
23use crate::cli::SparqlQueryArgs;
24use crate::client::DspClient;
25use crate::config::{AuthCache, Config, resolve_token};
26use crate::diagnostic::Diagnostic;
27
28fn resolve_accept(arg: Option<&str>) -> Result<String, Diagnostic> {
39 let Some(s) = arg else {
40 return Ok("application/sparql-results+json".to_string());
41 };
42
43 if s.is_empty() {
44 return Err(Diagnostic::Usage("--accept must not be empty".into()));
45 }
46
47 if s.contains('/') {
53 if s.chars().any(|c| c.is_control()) {
57 return Err(Diagnostic::Usage("--accept must not contain control characters".into()));
58 }
59 if s.chars().count() > 200 {
60 return Err(Diagnostic::Usage("--accept is too long (max 200 characters)".into()));
61 }
62 return Ok(s.to_string());
63 }
64
65 match s {
66 "json" => Ok("application/sparql-results+json".to_string()),
67 "xml" => Ok("application/sparql-results+xml".to_string()),
68 "csv" => Ok("text/csv".to_string()),
69 "tsv" => Ok("text/tab-separated-values".to_string()),
70 "turtle" => Ok("text/turtle".to_string()),
71 "ntriples" => Ok("application/n-triples".to_string()),
72 "jsonld" => Ok("application/ld+json".to_string()),
73 other => Err(Diagnostic::Usage(format!(
80 "unknown --accept alias '{}'; valid aliases: json, xml, csv, tsv, turtle, \
81 ntriples, jsonld — or pass a raw media type containing '/'",
82 crate::util::text::sanitise_and_cap(other)
83 ))),
84 }
85}
86
87fn resolve_query(
93 args: &SparqlQueryArgs,
94 stdin_is_tty: bool,
95 stdin_text: Option<String>,
96 file_text: Option<String>,
97) -> Result<String, Diagnostic> {
98 let text = if let Some(q) = &args.query {
99 q.clone()
100 } else if args.query_file.is_some() {
101 file_text.ok_or_else(|| Diagnostic::Internal("--query-file was given but its contents were not read".into()))?
104 } else if let Some(s) = stdin_text {
105 s
106 } else if stdin_is_tty {
107 return Err(Diagnostic::Usage(
108 "no query given: provide --query <text>, --query-file <path>, or pipe the query \
109 via stdin — dsp-cli will not wait on an interactive terminal"
110 .into(),
111 ));
112 } else {
113 return Err(Diagnostic::Usage(
118 "no query given: provide --query <text>, --query-file <path>, or pipe the query \
119 via stdin"
120 .into(),
121 ));
122 };
123
124 if text.trim().is_empty() {
125 return Err(Diagnostic::Usage("the SPARQL query text must not be empty".into()));
126 }
127
128 Ok(text)
129}
130
131fn read_query_file(path: &str) -> Result<String, Diagnostic> {
137 let safe_path = crate::util::text::sanitise_and_cap(path);
140
141 let p = Path::new(path);
142 if p.is_dir() {
143 return Err(Diagnostic::Usage(format!(
144 "--query-file '{safe_path}' is a directory, not a file"
145 )));
146 }
147
148 let bytes =
149 std::fs::read(p).map_err(|e| Diagnostic::Usage(format!("could not read --query-file '{safe_path}': {e}")))?;
150
151 String::from_utf8(bytes).map_err(|_| Diagnostic::Usage(format!("--query-file '{safe_path}' is not valid UTF-8")))
152}
153
154pub fn run(args: &SparqlQueryArgs, cfg: &Config, client: &dyn DspClient) -> Result<(), Diagnostic> {
161 let stdin_is_tty = io::stdin().is_terminal();
162
163 let file_text = match &args.query_file {
164 Some(path) => Some(read_query_file(path)?),
165 None => None,
166 };
167
168 let stdin_text = if args.query.is_none() && args.query_file.is_none() && !stdin_is_tty {
169 let mut buf = String::new();
170 io::stdin()
171 .read_to_string(&mut buf)
172 .map_err(|e| Diagnostic::Usage(format!("could not read query from stdin: {e}")))?;
173 Some(buf)
174 } else {
175 None
176 };
177
178 let env_token = std::env::var("DSP_TOKEN").ok();
179 let mut out = crate::util::BrokenPipeWriter::new(io::stdout());
182
183 query(
184 args,
185 cfg,
186 client,
187 env_token,
188 None,
189 stdin_is_tty,
190 stdin_text,
191 file_text,
192 &mut out,
193 )
194}
195
196#[allow(clippy::too_many_arguments)]
210fn query(
211 args: &SparqlQueryArgs,
212 cfg: &Config,
213 client: &dyn DspClient,
214 env_token: Option<String>,
215 cache_path: Option<&Path>,
216 stdin_is_tty: bool,
217 stdin_text: Option<String>,
218 file_text: Option<String>,
219 out: &mut dyn Write,
220) -> Result<(), Diagnostic> {
221 let env_token_would_win = env_token.as_deref().map(str::trim).map(|s| !s.is_empty()).unwrap_or(false);
223
224 let cache_result = match cache_path {
225 Some(p) => AuthCache::load_from(p),
226 None => AuthCache::load(),
227 };
228 let cache = match cache_result {
229 Ok(c) => c,
230 Err(e) if env_token_would_win => {
231 crate::util::warn_auth_cache_load_failed(&e, "DSP_TOKEN is set, falling through to env token");
232 AuthCache::default()
233 }
234 Err(e) => return Err(e),
235 };
236
237 let resolved = resolve_token(env_token, &cache, &cfg.server).ok_or_else(|| {
238 Diagnostic::AuthRequired(
239 "dsp vre sparql query requires a system-administrator token; run `dsp auth login` \
240 or set DSP_TOKEN"
241 .into(),
242 )
243 })?;
244
245 let query_text = resolve_query(args, stdin_is_tty, stdin_text, file_text)?;
247 let accept = resolve_accept(args.accept.as_deref())?;
248
249 let resp = client.sparql_query(&cfg.server, &resolved.token, &query_text, &accept, args.timeout)?;
251
252 if (200..300).contains(&resp.status) {
254 out.write_all(&resp.body)?;
255 out.flush()?;
256 Ok(())
257 } else {
258 let sanitised = crate::util::text::sanitise_bytes_for_prose(&resp.body);
262 Err(Diagnostic::ServerError(format!(
263 "the triplestore rejected the query (HTTP {}): {sanitised}",
264 resp.status
265 )))
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use std::cell::RefCell;
272 use std::collections::HashMap;
273
274 use super::*;
275 use crate::client::sparql::SparqlResponse;
276 use crate::model::ProjectRef;
277
278 #[test]
281 fn resolve_accept_defaults_to_json() {
282 assert_eq!(resolve_accept(None).unwrap(), "application/sparql-results+json");
283 }
284
285 #[test]
286 fn resolve_accept_every_alias() {
287 assert_eq!(resolve_accept(Some("json")).unwrap(), "application/sparql-results+json");
288 assert_eq!(resolve_accept(Some("xml")).unwrap(), "application/sparql-results+xml");
289 assert_eq!(resolve_accept(Some("csv")).unwrap(), "text/csv");
290 assert_eq!(resolve_accept(Some("tsv")).unwrap(), "text/tab-separated-values");
291 assert_eq!(resolve_accept(Some("turtle")).unwrap(), "text/turtle");
292 assert_eq!(resolve_accept(Some("ntriples")).unwrap(), "application/n-triples");
293 assert_eq!(resolve_accept(Some("jsonld")).unwrap(), "application/ld+json");
294 }
295
296 #[test]
297 fn resolve_accept_raw_media_type_is_forwarded_verbatim() {
298 assert_eq!(
299 resolve_accept(Some("text/csv;q=1, */*;q=0.1")).unwrap(),
300 "text/csv;q=1, */*;q=0.1"
301 );
302 }
303
304 #[test]
305 fn resolve_accept_unknown_alias_is_usage_error() {
306 let err = resolve_accept(Some("jsonn")).unwrap_err();
307 assert!(matches!(err, Diagnostic::Usage(_)));
308 }
309
310 #[test]
311 fn resolve_accept_empty_is_usage_error() {
312 let err = resolve_accept(Some("")).unwrap_err();
313 assert!(matches!(err, Diagnostic::Usage(_)));
314 }
315
316 #[test]
317 fn resolve_accept_control_character_is_usage_error() {
318 let err = resolve_accept(Some("text/csv\r\nX-Evil: 1")).unwrap_err();
319 assert!(matches!(err, Diagnostic::Usage(_)));
320 }
321
322 #[test]
323 fn resolve_accept_overlong_raw_type_is_usage_error() {
324 let long = format!("text/{}", "x".repeat(300));
325 let err = resolve_accept(Some(&long)).unwrap_err();
326 assert!(matches!(err, Diagnostic::Usage(_)));
327 }
328
329 fn args_with(query: Option<&str>, query_file: Option<&str>) -> SparqlQueryArgs {
332 SparqlQueryArgs {
333 server: Some("https://example.org".to_string()),
334 query: query.map(str::to_string),
335 query_file: query_file.map(str::to_string),
336 accept: None,
337 timeout: 3600,
338 }
339 }
340
341 #[test]
342 fn resolve_query_from_flag() {
343 let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
344 let text = resolve_query(&args, false, None, None).unwrap();
345 assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
346 }
347
348 #[test]
349 fn resolve_query_from_file_text() {
350 let args = args_with(None, Some("query.rq"));
351 let text = resolve_query(&args, false, None, Some("SELECT * WHERE { ?s ?p ?o }".into())).unwrap();
352 assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
353 }
354
355 #[test]
356 fn resolve_query_from_stdin() {
357 let args = args_with(None, None);
358 let text = resolve_query(&args, false, Some("SELECT * WHERE { ?s ?p ?o }".into()), None).unwrap();
359 assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
360 }
361
362 #[test]
363 fn resolve_query_tty_with_no_flag_is_usage_error() {
364 let args = args_with(None, None);
365 let err = resolve_query(&args, true, None, None).unwrap_err();
366 assert!(matches!(err, Diagnostic::Usage(_)));
367 }
368
369 #[test]
370 fn resolve_query_empty_is_usage_error() {
371 let args = args_with(Some(" "), None);
372 let err = resolve_query(&args, false, None, None).unwrap_err();
373 assert!(matches!(err, Diagnostic::Usage(_)));
374 }
375
376 struct MockDspClient {
379 sparql_query_result: Option<Result<SparqlResponse, Diagnostic>>,
380 sparql_query_calls: RefCell<u32>,
381 sparql_query_accept: RefCell<Option<String>>,
382 }
383
384 impl MockDspClient {
385 fn new() -> Self {
386 Self {
387 sparql_query_result: None,
388 sparql_query_calls: RefCell::new(0),
389 sparql_query_accept: RefCell::new(None),
390 }
391 }
392
393 fn with_sparql_query(mut self, result: Result<SparqlResponse, Diagnostic>) -> Self {
394 self.sparql_query_result = Some(result);
395 self
396 }
397
398 fn calls(&self) -> u32 {
399 *self.sparql_query_calls.borrow()
400 }
401
402 fn accept(&self) -> Option<String> {
403 self.sparql_query_accept.borrow().clone()
404 }
405 }
406
407 impl DspClient for MockDspClient {
408 fn login(
409 &self,
410 _server: &str,
411 _user: &str,
412 _password: &str,
413 ) -> Result<crate::model::LoginResponse, Diagnostic> {
414 unimplemented!("login not used in sparql action tests")
415 }
416
417 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
418 unimplemented!("resolve_project not used in sparql action tests")
419 }
420
421 fn create_project_dump(
422 &self,
423 _server: &str,
424 _project_iri: &str,
425 _skip_assets: bool,
426 _token: &str,
427 ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
428 unimplemented!("create_project_dump not used in sparql action tests")
429 }
430
431 fn get_project_dump_status(
432 &self,
433 _server: &str,
434 _project_iri: &str,
435 _dump_id: &str,
436 _token: &str,
437 ) -> Result<crate::model::DumpTask, Diagnostic> {
438 unimplemented!("get_project_dump_status not used in sparql action tests")
439 }
440
441 fn download_project_dump(
442 &self,
443 _server: &str,
444 _project_iri: &str,
445 _dump_id: &str,
446 _token: &str,
447 _dest: &mut dyn std::io::Write,
448 ) -> Result<u64, Diagnostic> {
449 unimplemented!("download_project_dump not used in sparql action tests")
450 }
451
452 fn delete_project_dump(
453 &self,
454 _server: &str,
455 _project_iri: &str,
456 _dump_id: &str,
457 _token: &str,
458 ) -> Result<(), Diagnostic> {
459 unimplemented!("delete_project_dump not used in sparql action tests")
460 }
461
462 fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
463 unimplemented!("list_projects not used in sparql action tests")
464 }
465
466 fn describe_project(
467 &self,
468 _server: &str,
469 _project: &str,
470 _token: Option<&str>,
471 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
472 unimplemented!("describe_project not used in sparql action tests")
473 }
474
475 fn list_data_models(
476 &self,
477 _server: &str,
478 _project_iri: &str,
479 _token: Option<&str>,
480 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
481 unimplemented!("list_data_models not used in sparql action tests")
482 }
483
484 fn describe_data_model(
485 &self,
486 _server: &str,
487 _data_model_iri: &str,
488 _token: Option<&str>,
489 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
490 unimplemented!("describe_data_model not used in sparql action tests")
491 }
492
493 fn describe_resource_type(
494 &self,
495 _server: &str,
496 _data_model_iri: &str,
497 _resource_type: &str,
498 _token: Option<&str>,
499 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
500 unimplemented!("describe_resource_type not used in sparql action tests")
501 }
502
503 fn resource_counts(
504 &self,
505 _server: &str,
506 _project_iri: &str,
507 _token: Option<&str>,
508 ) -> Result<HashMap<String, u64>, Diagnostic> {
509 unimplemented!("resource_counts not used in sparql action tests")
510 }
511
512 fn data_model_structure(
513 &self,
514 _server: &str,
515 _data_model_iri: &str,
516 _token: Option<&str>,
517 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
518 unimplemented!("data_model_structure not used in sparql action tests")
519 }
520
521 fn list_resources(
522 &self,
523 _server: &str,
524 _project_iri: &str,
525 _resource_type_iri: &str,
526 _order_by: Option<&str>,
527 _page: u32,
528 _token: Option<&str>,
529 ) -> Result<crate::model::ResourcePage, Diagnostic> {
530 unimplemented!("list_resources not used in sparql action tests")
531 }
532
533 fn describe_resource(
534 &self,
535 _server: &str,
536 _resource_iri: &str,
537 _token: Option<&str>,
538 _with_values: bool,
539 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
540 unimplemented!("describe_resource not used in sparql action tests")
541 }
542
543 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
544 unimplemented!("verify_token not used in sparql action tests")
545 }
546
547 fn list_vocabularies(
548 &self,
549 _server: &str,
550 _project_iri: &str,
551 _token: Option<&str>,
552 ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
553 unimplemented!("list_vocabularies not used in sparql action tests")
554 }
555
556 fn describe_vocabulary(
557 &self,
558 _server: &str,
559 _iri: &str,
560 _token: Option<&str>,
561 ) -> Result<crate::model::VocabularyTree, Diagnostic> {
562 unimplemented!("describe_vocabulary not used in sparql action tests")
563 }
564
565 fn sparql_query(
566 &self,
567 _server: &str,
568 _token: &str,
569 _query: &str,
570 accept: &str,
571 _timeout_secs: u64,
572 ) -> Result<SparqlResponse, Diagnostic> {
573 *self.sparql_query_calls.borrow_mut() += 1;
574 *self.sparql_query_accept.borrow_mut() = Some(accept.to_string());
575 self.sparql_query_result
576 .clone()
577 .expect("sparql_query_result must be set when sparql_query is called")
578 }
579 }
580
581 fn cfg() -> Config {
582 Config { server: "https://example.org".to_string() }
583 }
584
585 fn empty_cache_dir() -> tempfile::TempDir {
586 tempfile::tempdir().expect("tempdir")
587 }
588
589 #[test]
592 fn success_writes_exact_bytes_to_out() {
593 let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
594 status: 200,
595 content_type: Some("application/sparql-results+json".to_string()),
596 body: b"{\"results\":{\"bindings\":[]}}".to_vec(),
597 }));
598 let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
599 let dir = empty_cache_dir();
600 let cache_path = dir.path().join("auth.toml");
601 let mut out = Vec::new();
602
603 query(
604 &args,
605 &cfg(),
606 &client,
607 Some("a-token".to_string()),
608 Some(&cache_path),
609 false,
610 None,
611 None,
612 &mut out,
613 )
614 .expect("2xx relay must succeed");
615
616 assert_eq!(out, b"{\"results\":{\"bindings\":[]}}");
617 assert_eq!(client.calls(), 1);
618 }
619
620 #[test]
621 fn store_400_maps_to_server_error_and_writes_nothing() {
622 let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
623 status: 400,
624 content_type: Some("text/plain".to_string()),
625 body: b"Parse error: \x1b]0;pwned\x07line 1, column 1: nonsense".to_vec(),
629 }));
630 let args = args_with(Some("not a query"), None);
631 let dir = empty_cache_dir();
632 let cache_path = dir.path().join("auth.toml");
633 let mut out = Vec::new();
634
635 let err = query(
636 &args,
637 &cfg(),
638 &client,
639 Some("a-token".to_string()),
640 Some(&cache_path),
641 false,
642 None,
643 None,
644 &mut out,
645 )
646 .expect_err("a store 400 must be Err");
647
648 match err {
649 Diagnostic::ServerError(msg) => {
650 assert!(msg.contains("Parse error"), "message must contain the store's text: {msg}");
651 assert!(
652 !msg.contains('\u{1b}') && !msg.contains('\u{7}'),
653 "the relay path must strip control characters (D7): {msg:?}"
654 );
655 }
656 other => panic!("expected ServerError, got: {other:?}"),
657 }
658 assert!(out.is_empty(), "nothing must be written to stdout on a relayed rejection");
659 }
660
661 #[test]
662 fn no_token_is_auth_required_and_client_is_never_called() {
663 let client = MockDspClient::new();
664 let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
665 let dir = empty_cache_dir();
666 let cache_path = dir.path().join("auth.toml");
667 let mut out = Vec::new();
668
669 let err = query(&args, &cfg(), &client, None, Some(&cache_path), false, None, None, &mut out)
670 .expect_err("no token must be Err");
671
672 assert!(matches!(err, Diagnostic::AuthRequired(_)));
673 assert_eq!(client.calls(), 0, "the client must never be called without a token");
674 assert!(out.is_empty());
675 }
676
677 #[test]
678 fn accept_alias_reaches_the_client_as_its_media_type() {
679 let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
680 status: 200,
681 content_type: Some("text/csv".to_string()),
682 body: b"s,p,o\n".to_vec(),
683 }));
684 let mut args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
685 args.accept = Some("csv".to_string());
686 let dir = empty_cache_dir();
687 let cache_path = dir.path().join("auth.toml");
688 let mut out = Vec::new();
689
690 query(
691 &args,
692 &cfg(),
693 &client,
694 Some("a-token".to_string()),
695 Some(&cache_path),
696 false,
697 None,
698 None,
699 &mut out,
700 )
701 .expect("2xx relay must succeed");
702
703 assert_eq!(client.accept(), Some("text/csv".to_string()));
704 }
705}