1use std::ffi::OsString;
2use std::fs;
3use std::io::{Read, Seek, Write};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use dicom_core::value::{PixelFragmentSequence, Value};
8use dicom_dictionary_std::tags;
9use serde::{Deserialize, Serialize};
10
11use crate::{Error, TransferSyntax};
12
13mod process;
14
15use process::{CommandOutcome, SystemCommandRunner, ValidationCommandRunner};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(default)]
20#[non_exhaustive]
21pub struct ValidationOptions {
22 pub strict: bool,
24 pub dcmvalidate_iod: Option<PathBuf>,
26 pub htj2k_decoder: Option<String>,
28 pub max_pixel_frames: usize,
30 pub command_timeout_secs: u64,
32 pub max_files: usize,
34 pub max_depth: usize,
36 pub max_child_output_bytes: usize,
38 pub max_pixel_frame_bytes: usize,
40}
41
42impl Default for ValidationOptions {
43 fn default() -> Self {
44 Self {
45 strict: false,
46 dcmvalidate_iod: None,
47 htj2k_decoder: None,
48 max_pixel_frames: 1,
49 command_timeout_secs: 60,
50 max_files: 100_000,
51 max_depth: 64,
52 max_child_output_bytes: 4 * 1024 * 1024,
53 max_pixel_frame_bytes: 512 * 1024 * 1024,
54 }
55 }
56}
57
58impl ValidationOptions {
59 pub fn command_timeout(&self) -> Duration {
61 Duration::from_secs(self.command_timeout_secs)
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(default)]
68#[non_exhaustive]
69pub struct DoctorOptions {
70 pub strict: bool,
72 pub dcmvalidate_iod: Option<PathBuf>,
74 pub htj2k_decoder: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[non_exhaustive]
81pub struct DoctorReport {
82 pub tools: Vec<DoctorTool>,
84}
85
86impl DoctorReport {
87 pub fn has_failures(&self) -> bool {
89 self.tools
90 .iter()
91 .any(|tool| tool.status == DoctorStatus::Failed)
92 }
93
94 pub fn available_tools(&self) -> usize {
96 self.tools
97 .iter()
98 .filter(|tool| tool.status == DoctorStatus::Available)
99 .count()
100 }
101
102 pub fn failed_tools(&self) -> usize {
104 self.tools
105 .iter()
106 .filter(|tool| tool.status == DoctorStatus::Failed)
107 .count()
108 }
109
110 pub fn missing_tools(&self) -> usize {
112 self.tools
113 .iter()
114 .filter(|tool| tool.status == DoctorStatus::Missing)
115 .count()
116 }
117
118 pub fn skipped_tools(&self) -> usize {
120 self.tools
121 .iter()
122 .filter(|tool| tool.status == DoctorStatus::Skipped)
123 .count()
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[non_exhaustive]
130pub struct DoctorTool {
131 pub name: String,
133 pub required: bool,
135 pub status: DoctorStatus,
137 pub command: Vec<String>,
139 pub path: Option<PathBuf>,
141 pub message: String,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum DoctorStatus {
150 Available,
152 Missing,
154 Failed,
156 Skipped,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162#[non_exhaustive]
163pub struct ValidationReport {
164 pub input: PathBuf,
166 pub files: Vec<PathBuf>,
168 pub checks: Vec<ValidationCheck>,
170}
171
172impl ValidationReport {
173 pub fn has_failures(&self) -> bool {
175 self.checks
176 .iter()
177 .any(|check| check.status == ValidationStatus::Failed)
178 }
179
180 pub fn passed_checks(&self) -> usize {
182 self.checks
183 .iter()
184 .filter(|check| check.status == ValidationStatus::Passed)
185 .count()
186 }
187
188 pub fn failed_checks(&self) -> usize {
190 self.checks
191 .iter()
192 .filter(|check| check.status == ValidationStatus::Failed)
193 .count()
194 }
195
196 pub fn skipped_checks(&self) -> usize {
198 self.checks
199 .iter()
200 .filter(|check| check.status == ValidationStatus::Skipped)
201 .count()
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207#[non_exhaustive]
208pub struct ValidationCheck {
209 pub name: String,
211 pub path: Option<PathBuf>,
213 pub status: ValidationStatus,
215 pub command: Vec<String>,
217 pub message: String,
219 pub stdout: String,
221 pub stderr: String,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
227#[serde(rename_all = "snake_case")]
228#[non_exhaustive]
229pub enum ValidationStatus {
230 Passed,
232 Failed,
234 Skipped,
236}
237
238const DOCTOR_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241struct ValidatorToolSpec {
242 name: &'static str,
243 required: bool,
244 doctor_args: &'static [&'static str],
245 nonzero_success_output: Option<&'static str>,
246}
247
248const DCIODVFY_TOOL: ValidatorToolSpec = ValidatorToolSpec {
249 name: "dciodvfy",
250 required: true,
251 doctor_args: &["-version"],
252 nonzero_success_output: None,
253};
254const DCENTVFY_TOOL: ValidatorToolSpec = ValidatorToolSpec {
255 name: "dcentvfy",
256 required: true,
257 doctor_args: &["-version"],
258 nonzero_success_output: None,
259};
260const VALIDATE_IODS_TOOL: ValidatorToolSpec = ValidatorToolSpec {
261 name: "validate_iods",
262 required: false,
263 doctor_args: &["-h"],
264 nonzero_success_output: None,
265};
266const DJPEG_TOOL: ValidatorToolSpec = ValidatorToolSpec {
267 name: "djpeg",
268 required: false,
269 doctor_args: &["-version"],
270 nonzero_success_output: None,
271};
272const OPJ_DECOMPRESS_TOOL: ValidatorToolSpec = ValidatorToolSpec {
273 name: "opj_decompress",
274 required: false,
275 doctor_args: &["-h"],
276 nonzero_success_output: Some("OpenJPEG"),
277};
278const DCMVALIDATE_TOOL: ValidatorToolSpec = ValidatorToolSpec {
279 name: "dcmvalidate",
280 required: true,
281 doctor_args: &["--help"],
282 nonzero_success_output: None,
283};
284const VALIDATOR_DOCTOR_TOOLS: &[ValidatorToolSpec] = &[
285 DCIODVFY_TOOL,
286 DCENTVFY_TOOL,
287 VALIDATE_IODS_TOOL,
288 DJPEG_TOOL,
289 OPJ_DECOMPRESS_TOOL,
290];
291const AUTO_HTJ2K_DECODER_COMMAND: &str = "grk_decompress";
292const VALIDATOR_SET_FILE_CHUNK_SIZE: usize = 512;
293
294fn staged_dicom3tools_command(name: &str) -> Option<PathBuf> {
295 if !staged_dicom3tools_probe_enabled() {
296 return None;
297 }
298 let staged = Path::new(env!("CARGO_MANIFEST_DIR"))
299 .join("target")
300 .join("dicom3tools-mac")
301 .join(name);
302 staged.is_file().then_some(staged)
303}
304
305fn staged_dicom3tools_probe_enabled() -> bool {
306 staged_dicom3tools_probe_enabled_from(
307 cfg!(debug_assertions),
308 std::env::var_os("WSI_DICOM_VALIDATOR_STAGED_TOOLS").is_some(),
309 )
310}
311
312fn staged_dicom3tools_probe_enabled_from(debug_assertions: bool, env_present: bool) -> bool {
313 debug_assertions || env_present
314}
315
316pub fn validate_dicom_path(
318 path: impl AsRef<Path>,
319 options: &ValidationOptions,
320) -> Result<ValidationReport, Error> {
321 validate_dicom_path_with_runner(path.as_ref(), options, &SystemCommandRunner)
322}
323
324pub fn doctor_dicom_environment(options: &DoctorOptions) -> DoctorReport {
326 doctor_dicom_environment_with_runner(options, &SystemCommandRunner)
327}
328
329pub(crate) fn doctor_dicom_environment_with_runner(
330 options: &DoctorOptions,
331 runner: &impl ValidationCommandRunner,
332) -> DoctorReport {
333 let mut tools = VALIDATOR_DOCTOR_TOOLS
334 .iter()
335 .map(|tool| doctor_command_tool(runner, tool, options.strict))
336 .collect::<Vec<_>>();
337
338 tools.push(match &options.dcmvalidate_iod {
339 Some(_iod) => doctor_command_tool(runner, &DCMVALIDATE_TOOL, options.strict),
340 None => skipped_doctor_tool(
341 "dcmvalidate",
342 false,
343 "dcmvalidate IOD path is not configured".to_string(),
344 ),
345 });
346 tools.push(doctor_htj2k_decoder_tool(runner, options));
347
348 DoctorReport { tools }
349}
350
351fn doctor_command_tool(
352 runner: &impl ValidationCommandRunner,
353 tool: &ValidatorToolSpec,
354 strict: bool,
355) -> DoctorTool {
356 let args = tool
357 .doctor_args
358 .iter()
359 .map(|arg| OsString::from(*arg))
360 .collect::<Vec<_>>();
361 let command = std::iter::once(tool.name.to_string())
362 .chain(tool.doctor_args.iter().map(|arg| (*arg).to_string()))
363 .collect::<Vec<_>>();
364 match runner.find_command(tool.name) {
365 Some(path) => match runner.run(&path, &args, DOCTOR_PROBE_TIMEOUT, 4 * 1024 * 1024) {
366 Ok(outcome) if doctor_probe_passed(tool, &outcome) => DoctorTool {
367 name: tool.name.to_string(),
368 required: tool.required,
369 status: DoctorStatus::Available,
370 command,
371 path: Some(path),
372 message: format!("{} probe passed", tool.name),
373 },
374 Ok(outcome) => {
375 let message = if outcome.timed_out {
376 format!(
377 "{} probe timed out after {}",
378 tool.name,
379 format_timeout(DOCTOR_PROBE_TIMEOUT)
380 )
381 } else {
382 format!("{} probe failed", tool.name)
383 };
384 DoctorTool {
385 name: tool.name.to_string(),
386 required: tool.required,
387 status: DoctorStatus::Failed,
388 command,
389 path: Some(path),
390 message,
391 }
392 }
393 Err(source) => DoctorTool {
394 name: tool.name.to_string(),
395 required: tool.required,
396 status: DoctorStatus::Failed,
397 command,
398 path: Some(path),
399 message: format!("failed to start {}: {source}", tool.name),
400 },
401 },
402 None => {
403 let status = if strict && tool.required {
404 DoctorStatus::Failed
405 } else {
406 DoctorStatus::Missing
407 };
408 DoctorTool {
409 name: tool.name.to_string(),
410 required: tool.required,
411 status,
412 command,
413 path: None,
414 message: format!("{} not found", tool.name),
415 }
416 }
417 }
418}
419
420fn doctor_probe_passed(tool: &ValidatorToolSpec, outcome: &CommandOutcome) -> bool {
421 !outcome.timed_out
422 && (outcome.success
423 || tool
424 .nonzero_success_output
425 .is_some_and(|needle| output_contains_probe_needle(outcome, needle)))
426}
427
428fn output_contains_probe_needle(outcome: &CommandOutcome, needle: &str) -> bool {
429 outcome.stdout.contains(needle) || outcome.stderr.contains(needle)
430}
431
432fn doctor_htj2k_decoder_tool(
433 runner: &impl ValidationCommandRunner,
434 options: &DoctorOptions,
435) -> DoctorTool {
436 let configured = options.htj2k_decoder.is_some();
437 let template = match options
438 .htj2k_decoder
439 .clone()
440 .or_else(|| auto_htj2k_decoder_template(runner))
441 {
442 Some(template) => template,
443 None => {
444 let status = if options.strict {
445 DoctorStatus::Failed
446 } else {
447 DoctorStatus::Skipped
448 };
449 return DoctorTool {
450 name: "htj2k_decoder".to_string(),
451 required: options.strict,
452 status,
453 command: Vec::new(),
454 path: None,
455 message: "HTJ2K decoder command is not configured and grk_decompress was not found"
456 .to_string(),
457 };
458 }
459 };
460 let (name, args) =
461 match htj2k_decoder_command(&template, Path::new("input.jhc"), Path::new("output.ppm")) {
462 Ok(command) => command,
463 Err(message) => {
464 return DoctorTool {
465 name: "htj2k_decoder".to_string(),
466 required: options.strict || configured,
467 status: DoctorStatus::Failed,
468 command: Vec::new(),
469 path: None,
470 message,
471 };
472 }
473 };
474 let command = std::iter::once(name.clone())
475 .chain(args.iter().map(|arg| arg.to_string_lossy().into_owned()))
476 .collect::<Vec<_>>();
477 match runner.find_command(&name) {
478 Some(path) => DoctorTool {
479 name: "htj2k_decoder".to_string(),
480 required: options.strict || configured,
481 status: DoctorStatus::Available,
482 command,
483 path: Some(path),
484 message: if configured {
485 format!("{name} found")
486 } else {
487 format!("{name} auto-detected")
488 },
489 },
490 None => DoctorTool {
491 name: "htj2k_decoder".to_string(),
492 required: options.strict || configured,
493 status: DoctorStatus::Failed,
494 command,
495 path: None,
496 message: format!("{name} not found"),
497 },
498 }
499}
500
501fn auto_htj2k_decoder_template(runner: &impl ValidationCommandRunner) -> Option<String> {
502 let path = runner.find_command(AUTO_HTJ2K_DECODER_COMMAND)?;
503 path.is_absolute().then(|| {
504 format!(
505 "{} -i {{input}} -o {{output}}",
506 shlex_quote_path_for_template(&path)
507 )
508 })
509}
510
511fn shlex_quote_path_for_template(path: &Path) -> String {
512 let path = path.to_string_lossy();
513 if path.chars().any(char::is_whitespace) || path.contains('\'') || path.contains('"') {
514 let escaped = path.replace('\'', r"'\''");
515 format!("'{escaped}'")
516 } else {
517 path.into_owned()
518 }
519}
520
521fn skipped_doctor_tool(name: &str, required: bool, message: String) -> DoctorTool {
522 DoctorTool {
523 name: name.to_string(),
524 required,
525 status: DoctorStatus::Skipped,
526 command: Vec::new(),
527 path: None,
528 message,
529 }
530}
531
532pub(crate) fn validate_dicom_path_with_runner(
533 path: impl AsRef<Path>,
534 options: &ValidationOptions,
535 runner: &impl ValidationCommandRunner,
536) -> Result<ValidationReport, Error> {
537 let input = path.as_ref().to_path_buf();
538 let files = discover_dicom_files(&input, options)?;
539 let mut checks = Vec::new();
540
541 for file in &files {
542 checks.push(run_named_command_check(
543 runner,
544 CommandCheckRequest {
545 check_name: DCIODVFY_TOOL.name,
546 command_name: DCIODVFY_TOOL.name,
547 args: vec![OsString::from("-new"), file.as_os_str().to_os_string()],
548 path: Some(file),
549 required: options.strict,
550 error_line_is_failure: true,
551 timeout: options.command_timeout(),
552 max_output_bytes: options.max_child_output_bytes,
553 },
554 ));
555 }
556
557 checks.extend(run_set_level_command_checks(
558 runner,
559 &files,
560 SetLevelCommandCheckRequest {
561 check_name: DCENTVFY_TOOL.name,
562 command_name: DCENTVFY_TOOL.name,
563 required: options.strict,
564 error_line_is_failure: true,
565 timeout: options.command_timeout(),
566 max_output_bytes: options.max_child_output_bytes,
567 chunk_size: VALIDATOR_SET_FILE_CHUNK_SIZE,
568 },
569 ));
570
571 checks.extend(run_set_level_command_checks(
572 runner,
573 &files,
574 SetLevelCommandCheckRequest {
575 check_name: VALIDATE_IODS_TOOL.name,
576 command_name: VALIDATE_IODS_TOOL.name,
577 required: options.strict,
578 error_line_is_failure: false,
579 timeout: options.command_timeout(),
580 max_output_bytes: options.max_child_output_bytes,
581 chunk_size: VALIDATOR_SET_FILE_CHUNK_SIZE,
582 },
583 ));
584
585 if let Some(iod) = &options.dcmvalidate_iod {
586 for file in &files {
587 checks.push(run_named_command_check(
588 runner,
589 CommandCheckRequest {
590 check_name: DCMVALIDATE_TOOL.name,
591 command_name: DCMVALIDATE_TOOL.name,
592 args: vec![
593 OsString::from("--iod"),
594 iod.as_os_str().to_os_string(),
595 file.as_os_str().to_os_string(),
596 ],
597 path: Some(file),
598 required: true,
599 error_line_is_failure: false,
600 timeout: options.command_timeout(),
601 max_output_bytes: options.max_child_output_bytes,
602 },
603 ));
604 }
605 }
606
607 if options.max_pixel_frames > 0 {
608 let temp_dir = ValidationTempDir::create()?;
609 for (file_idx, file) in files.iter().enumerate() {
610 checks.extend(run_pixel_decode_checks(
611 file_idx,
612 file,
613 options,
614 runner,
615 temp_dir.path(),
616 ));
617 }
618 }
619
620 Ok(ValidationReport {
621 input,
622 files,
623 checks,
624 })
625}
626
627fn discover_dicom_files(input: &Path, options: &ValidationOptions) -> Result<Vec<PathBuf>, Error> {
628 let metadata = std::fs::symlink_metadata(input).map_err(|source| Error::Io {
629 path: input.to_path_buf(),
630 source,
631 })?;
632 let mut files = Vec::new();
633 if metadata.file_type().is_symlink() {
634 return Err(Error::Validation {
635 reason: format!("refusing to validate symlink path {}", input.display()),
636 });
637 } else if metadata.is_file() {
638 files.push(input.to_path_buf());
639 } else if metadata.is_dir() {
640 collect_dicom_files(input, options, &mut files)?;
641 files.sort();
642 } else {
643 return Err(Error::Validation {
644 reason: format!("{} is not a regular file or directory", input.display()),
645 });
646 }
647 if files.is_empty() {
648 return Err(Error::Validation {
649 reason: format!("no .dcm files found under {}", input.display()),
650 });
651 }
652 Ok(files)
653}
654
655fn collect_dicom_files(
656 root: &Path,
657 options: &ValidationOptions,
658 files: &mut Vec<PathBuf>,
659) -> Result<(), Error> {
660 let mut pending = vec![(root.to_path_buf(), 0usize)];
661 while let Some((dir, depth)) = pending.pop() {
662 if depth > options.max_depth {
663 return Err(Error::Validation {
664 reason: format!(
665 "DICOM validation directory depth exceeds max_depth={} at {}",
666 options.max_depth,
667 dir.display()
668 ),
669 });
670 }
671 let entries = std::fs::read_dir(&dir).map_err(|source| Error::Io {
672 path: dir.to_path_buf(),
673 source,
674 })?;
675 for entry in entries {
676 let entry = entry.map_err(|source| Error::Io {
677 path: dir.clone(),
678 source,
679 })?;
680 let path = entry.path();
681 let file_type = entry.file_type().map_err(|source| Error::Io {
682 path: path.clone(),
683 source,
684 })?;
685 if file_type.is_symlink() {
686 return Err(Error::Validation {
687 reason: format!("refusing to traverse symlink {}", path.display()),
688 });
689 } else if file_type.is_dir() {
690 pending.push((path, depth + 1));
691 } else if file_type.is_file() && has_dcm_extension(&path) {
692 files.push(path);
693 if files.len() > options.max_files {
694 return Err(Error::Validation {
695 reason: format!(
696 "DICOM validation found more than max_files={} files",
697 options.max_files
698 ),
699 });
700 }
701 }
702 }
703 }
704 Ok(())
705}
706
707fn has_dcm_extension(path: &Path) -> bool {
708 path.extension()
709 .and_then(|value| value.to_str())
710 .is_some_and(|extension| extension.eq_ignore_ascii_case("dcm"))
711}
712
713struct CommandCheckRequest<'a> {
714 check_name: &'a str,
715 command_name: &'a str,
716 args: Vec<OsString>,
717 path: Option<&'a PathBuf>,
718 required: bool,
719 error_line_is_failure: bool,
720 timeout: Duration,
721 max_output_bytes: usize,
722}
723
724struct SetLevelCommandCheckRequest<'a> {
725 check_name: &'a str,
726 command_name: &'a str,
727 required: bool,
728 error_line_is_failure: bool,
729 timeout: Duration,
730 max_output_bytes: usize,
731 chunk_size: usize,
732}
733
734fn run_set_level_command_checks(
735 runner: &impl ValidationCommandRunner,
736 files: &[PathBuf],
737 request: SetLevelCommandCheckRequest<'_>,
738) -> Vec<ValidationCheck> {
739 let chunk_size = request.chunk_size.max(1);
740 files
741 .chunks(chunk_size)
742 .map(|chunk| {
743 run_named_command_check(
744 runner,
745 CommandCheckRequest {
746 check_name: request.check_name,
747 command_name: request.command_name,
748 args: chunk
749 .iter()
750 .map(|file| file.as_os_str().to_os_string())
751 .collect(),
752 path: None,
753 required: request.required,
754 error_line_is_failure: request.error_line_is_failure,
755 timeout: request.timeout,
756 max_output_bytes: request.max_output_bytes,
757 },
758 )
759 })
760 .collect()
761}
762
763fn run_named_command_check(
764 runner: &impl ValidationCommandRunner,
765 request: CommandCheckRequest<'_>,
766) -> ValidationCheck {
767 let CommandCheckRequest {
768 check_name,
769 command_name,
770 args,
771 path,
772 required,
773 error_line_is_failure,
774 timeout,
775 max_output_bytes,
776 } = request;
777 let command = std::iter::once(command_name.to_string())
778 .chain(args.iter().map(|arg| arg.to_string_lossy().into_owned()))
779 .collect::<Vec<_>>();
780 let Some(program) = runner.find_command(command_name) else {
781 let status = if required {
782 ValidationStatus::Failed
783 } else {
784 ValidationStatus::Skipped
785 };
786 return ValidationCheck {
787 name: check_name.to_string(),
788 path: path.cloned(),
789 status,
790 command,
791 message: format!("{command_name} not found"),
792 stdout: String::new(),
793 stderr: String::new(),
794 };
795 };
796
797 match runner.run(&program, &args, timeout, max_output_bytes) {
798 Ok(outcome) => {
799 if outcome.stdout_truncated || outcome.stderr_truncated {
800 return ValidationCheck {
801 name: check_name.to_string(),
802 path: path.cloned(),
803 status: ValidationStatus::Failed,
804 command,
805 message: format!(
806 "{command_name} output exceeded {} byte capture limit",
807 max_output_bytes
808 ),
809 stdout: outcome.stdout,
810 stderr: outcome.stderr,
811 };
812 }
813 if outcome.timed_out {
814 return ValidationCheck {
815 name: check_name.to_string(),
816 path: path.cloned(),
817 status: ValidationStatus::Failed,
818 command,
819 message: format!("{command_name} timed out after {}", format_timeout(timeout)),
820 stdout: outcome.stdout,
821 stderr: outcome.stderr,
822 };
823 }
824 let output_has_error = error_line_is_failure
825 && outcome
826 .stdout
827 .lines()
828 .chain(outcome.stderr.lines())
829 .any(|line| line.trim_start().starts_with("Error"));
830 let status = if outcome.success && !output_has_error {
831 ValidationStatus::Passed
832 } else {
833 ValidationStatus::Failed
834 };
835 ValidationCheck {
836 name: check_name.to_string(),
837 path: path.cloned(),
838 status,
839 command,
840 message: if status == ValidationStatus::Passed {
841 format!("{command_name} passed")
842 } else {
843 format!("{command_name} failed")
844 },
845 stdout: outcome.stdout,
846 stderr: outcome.stderr,
847 }
848 }
849 Err(source) => ValidationCheck {
850 name: check_name.to_string(),
851 path: path.cloned(),
852 status: ValidationStatus::Failed,
853 command,
854 message: format!("failed to start {command_name}: {source}"),
855 stdout: String::new(),
856 stderr: String::new(),
857 },
858 }
859}
860
861fn format_timeout(timeout: Duration) -> String {
862 if timeout.as_millis() > 0 && timeout.as_millis() < 1000 {
863 format!("{}ms", timeout.as_millis())
864 } else if timeout.subsec_millis() == 0 {
865 format!("{}s", timeout.as_secs())
866 } else {
867 format!("{}ms", timeout.as_millis())
868 }
869}
870
871fn run_pixel_decode_checks(
872 file_idx: usize,
873 file: &PathBuf,
874 options: &ValidationOptions,
875 runner: &impl ValidationCommandRunner,
876 temp_dir: &Path,
877) -> Vec<ValidationCheck> {
878 let object = match dicom_object::open_file(file) {
879 Ok(object) => object,
880 Err(err) => {
881 return vec![failed_check(
882 "pixel-decode",
883 Some(file),
884 format!("failed to read DICOM file for pixel decode: {err}"),
885 )];
886 }
887 };
888 let transfer_syntax = object.meta().transfer_syntax.trim_end_matches('\0');
889 let Some(decoder) = pixel_decoder_for_transfer_syntax(transfer_syntax, options, runner) else {
890 return vec![skipped_check(
891 "pixel-decode",
892 Some(file),
893 format!("pixel decode not needed for transfer syntax {transfer_syntax}"),
894 )];
895 };
896 if let PixelDecoder::Htj2kUnconfigured = decoder {
897 let status = if options.strict {
898 ValidationStatus::Failed
899 } else {
900 ValidationStatus::Skipped
901 };
902 return vec![ValidationCheck {
903 name: "pixel-htj2k".to_string(),
904 path: Some(file.clone()),
905 status,
906 command: Vec::new(),
907 message: "HTJ2K decoder command is not configured".to_string(),
908 stdout: String::new(),
909 stderr: String::new(),
910 }];
911 }
912
913 let expected = match decoded_frame_expectation(&object) {
914 Ok(expected) => expected,
915 Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
916 };
917 let frame_count = match object.element(tags::NUMBER_OF_FRAMES) {
918 Ok(element) => match element.to_int::<usize>() {
919 Ok(frame_count) if frame_count > 0 => frame_count,
920 Ok(_) => {
921 return vec![failed_check(
922 "pixel-decode",
923 Some(file),
924 "DICOM Number of Frames must be greater than zero".to_string(),
925 )];
926 }
927 Err(err) => {
928 return vec![failed_check(
929 "pixel-decode",
930 Some(file),
931 format!("failed to read DICOM Number of Frames: {err}"),
932 )];
933 }
934 },
935 Err(err) => {
936 return vec![failed_check(
937 "pixel-decode",
938 Some(file),
939 format!("DICOM Number of Frames is missing: {err}"),
940 )];
941 }
942 };
943
944 let pixel_data = match object.element(tags::PIXEL_DATA) {
945 Ok(pixel_data) => pixel_data,
946 Err(err) => {
947 return vec![failed_check(
948 "pixel-decode",
949 Some(file),
950 format!("failed to read Pixel Data: {err}"),
951 )];
952 }
953 };
954 let Value::PixelSequence(pixel_sequence) = pixel_data.value() else {
955 return vec![skipped_check(
956 "pixel-decode",
957 Some(file),
958 "Pixel Data is not encapsulated".to_string(),
959 )];
960 };
961 if pixel_sequence.fragments().is_empty() {
962 return vec![skipped_check(
963 "pixel-decode",
964 Some(file),
965 "Pixel Data has no fragments".to_string(),
966 )];
967 }
968
969 let extended_offsets = match optional_u64_values(&object, tags::EXTENDED_OFFSET_TABLE) {
970 Ok(values) => values,
971 Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
972 };
973 let extended_lengths = match optional_u64_values(&object, tags::EXTENDED_OFFSET_TABLE_LENGTHS) {
974 Ok(values) => values,
975 Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
976 };
977 let frames = match assemble_encapsulated_frames(
978 pixel_sequence,
979 frame_count,
980 extended_offsets.as_deref(),
981 extended_lengths.as_deref(),
982 options.max_pixel_frames,
983 options.max_pixel_frame_bytes,
984 ) {
985 Ok(frames) => frames,
986 Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
987 };
988
989 let mut checks = Vec::new();
990 for (frame_idx, frame) in frames.iter().enumerate() {
991 checks.push(run_pixel_decoder_for_fragment(
992 &decoder,
993 PixelFragmentDecode {
994 file_idx,
995 frame_idx,
996 fragment: frame,
997 file,
998 runner,
999 temp_dir,
1000 strict: options.strict,
1001 timeout: options.command_timeout(),
1002 max_output_bytes: options.max_child_output_bytes,
1003 max_decoded_bytes: options.max_pixel_frame_bytes,
1004 expected,
1005 },
1006 ));
1007 }
1008 checks
1009}
1010
1011#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012struct DecodedFrameExpectation {
1013 columns: u32,
1014 rows: u32,
1015 samples_per_pixel: Option<u16>,
1016 bits_allocated: Option<u16>,
1017}
1018
1019fn decoded_frame_expectation(
1020 object: &dicom_object::DefaultDicomObject,
1021) -> Result<DecodedFrameExpectation, String> {
1022 let columns = object
1023 .element(tags::COLUMNS)
1024 .map_err(|err| format!("DICOM Columns is missing: {err}"))?
1025 .to_int::<u32>()
1026 .map_err(|err| format!("failed to read DICOM Columns: {err}"))?;
1027 let rows = object
1028 .element(tags::ROWS)
1029 .map_err(|err| format!("DICOM Rows is missing: {err}"))?
1030 .to_int::<u32>()
1031 .map_err(|err| format!("failed to read DICOM Rows: {err}"))?;
1032 if columns == 0 || rows == 0 {
1033 return Err("DICOM Rows and Columns must be greater than zero".to_string());
1034 }
1035 let samples_per_pixel = object
1036 .element(tags::SAMPLES_PER_PIXEL)
1037 .ok()
1038 .map(|element| {
1039 element
1040 .to_int::<u16>()
1041 .map_err(|err| format!("failed to read DICOM Samples per Pixel: {err}"))
1042 })
1043 .transpose()?;
1044 let bits_allocated = object
1045 .element(tags::BITS_ALLOCATED)
1046 .ok()
1047 .map(|element| {
1048 element
1049 .to_int::<u16>()
1050 .map_err(|err| format!("failed to read DICOM Bits Allocated: {err}"))
1051 })
1052 .transpose()?;
1053 Ok(DecodedFrameExpectation {
1054 columns,
1055 rows,
1056 samples_per_pixel,
1057 bits_allocated,
1058 })
1059}
1060
1061fn optional_u64_values(
1062 object: &dicom_object::DefaultDicomObject,
1063 tag: dicom_core::Tag,
1064) -> Result<Option<Vec<u64>>, String> {
1065 let Ok(element) = object.element(tag) else {
1066 return Ok(None);
1067 };
1068 element
1069 .to_multi_int::<u64>()
1070 .map(Some)
1071 .map_err(|err| format!("failed to read DICOM element {tag}: {err}"))
1072}
1073
1074fn assemble_encapsulated_frames(
1075 sequence: &PixelFragmentSequence<Vec<u8>>,
1076 frame_count: usize,
1077 extended_offsets: Option<&[u64]>,
1078 extended_lengths: Option<&[u64]>,
1079 max_frames: usize,
1080 max_frame_bytes: usize,
1081) -> Result<Vec<Vec<u8>>, String> {
1082 let fragments = sequence.fragments();
1083 if fragments.is_empty() {
1084 return Err("Pixel Data has no fragments".to_string());
1085 }
1086
1087 let basic_offsets = sequence.offset_table();
1088 let offsets = match extended_offsets {
1089 Some(offsets) if !offsets.is_empty() => offsets.to_vec(),
1090 _ if !basic_offsets.is_empty() => basic_offsets
1091 .iter()
1092 .map(|&value| u64::from(value))
1093 .collect(),
1094 _ => Vec::new(),
1095 };
1096 let lengths = extended_lengths.filter(|lengths| !lengths.is_empty());
1097 if lengths.is_some() && extended_offsets.is_none_or(<[u64]>::is_empty) {
1098 return Err("Extended Offset Table Lengths requires an Extended Offset Table".to_string());
1099 }
1100 if let Some(lengths) = lengths {
1101 if lengths.len() != frame_count {
1102 return Err(format!(
1103 "Extended Offset Table Lengths has {} entries for {frame_count} frames",
1104 lengths.len()
1105 ));
1106 }
1107 }
1108
1109 let spans = if offsets.is_empty() {
1110 if frame_count == 1 {
1111 vec![(0, fragments.len())]
1112 } else if frame_count == fragments.len() {
1113 (0..fragments.len())
1114 .map(|index| (index, index + 1))
1115 .collect()
1116 } else {
1117 return Err(format!(
1118 "cannot map {} Pixel Data fragments to {frame_count} frames without an offset table",
1119 fragments.len()
1120 ));
1121 }
1122 } else {
1123 if offsets.len() != frame_count {
1124 return Err(format!(
1125 "Pixel Data offset table has {} entries for {frame_count} frames",
1126 offsets.len()
1127 ));
1128 }
1129 let mut fragment_offsets = Vec::with_capacity(fragments.len());
1130 let mut next_offset = 0u64;
1131 for fragment in fragments {
1132 fragment_offsets.push(next_offset);
1133 let fragment_len = u64::try_from(fragment.len())
1134 .map_err(|_| "Pixel Data fragment length exceeds u64".to_string())?;
1135 next_offset = next_offset
1136 .checked_add(8)
1137 .and_then(|offset| offset.checked_add(fragment_len))
1138 .ok_or_else(|| "Pixel Data fragment offsets overflow u64".to_string())?;
1139 }
1140 let mut starts = Vec::with_capacity(offsets.len());
1141 for offset in offsets {
1142 let index = fragment_offsets.binary_search(&offset).map_err(|_| {
1143 format!("Pixel Data frame offset {offset} does not identify a fragment boundary")
1144 })?;
1145 starts.push(index);
1146 }
1147 if starts.first() != Some(&0) || starts.windows(2).any(|pair| pair[0] >= pair[1]) {
1148 return Err(
1149 "Pixel Data frame offsets are not strictly increasing from zero".to_string(),
1150 );
1151 }
1152 starts
1153 .iter()
1154 .enumerate()
1155 .map(|(index, &start)| {
1156 let end = starts.get(index + 1).copied().unwrap_or(fragments.len());
1157 (start, end)
1158 })
1159 .collect()
1160 };
1161
1162 let mut frames = Vec::with_capacity(max_frames.min(frame_count));
1163 for (frame_index, &(start, end)) in spans.iter().take(max_frames).enumerate() {
1164 let assembled_len = fragments[start..end]
1165 .iter()
1166 .try_fold(0usize, |total, fragment| {
1167 total
1168 .checked_add(fragment.len())
1169 .ok_or_else(|| "assembled Pixel Data frame length overflows usize".to_string())
1170 })?;
1171 let output_len = match lengths {
1172 Some(lengths) => usize::try_from(lengths[frame_index]).map_err(|_| {
1173 format!("Pixel Data frame {frame_index} length exceeds platform limits")
1174 })?,
1175 None => assembled_len,
1176 };
1177 if output_len > assembled_len {
1178 return Err(format!(
1179 "Pixel Data frame {frame_index} declares {output_len} bytes but only {assembled_len} are available"
1180 ));
1181 }
1182 if output_len > max_frame_bytes {
1183 return Err(format!(
1184 "Pixel Data frame {frame_index} exceeds {max_frame_bytes} byte validation limit"
1185 ));
1186 }
1187 let mut frame = Vec::with_capacity(output_len);
1188 for fragment in &fragments[start..end] {
1189 let remaining = output_len.saturating_sub(frame.len());
1190 if remaining == 0 {
1191 break;
1192 }
1193 frame.extend_from_slice(&fragment[..fragment.len().min(remaining)]);
1194 }
1195 frames.push(frame);
1196 }
1197 Ok(frames)
1198}
1199
1200enum PixelDecoder {
1201 Djpeg,
1202 OpenJpeg,
1203 Htj2kUnconfigured,
1204 Htj2k { template: String },
1205}
1206
1207fn pixel_decoder_for_transfer_syntax(
1208 transfer_syntax_uid: &str,
1209 options: &ValidationOptions,
1210 runner: &impl ValidationCommandRunner,
1211) -> Option<PixelDecoder> {
1212 match transfer_syntax_uid {
1213 uid if uid == TransferSyntax::JpegBaseline8Bit.uid() => Some(PixelDecoder::Djpeg),
1214 uid if uid == TransferSyntax::Jpeg2000.uid()
1215 || uid == TransferSyntax::Jpeg2000Lossless.uid() =>
1216 {
1217 Some(PixelDecoder::OpenJpeg)
1218 }
1219 uid if uid == TransferSyntax::Htj2k.uid()
1220 || uid == TransferSyntax::Htj2kLossless.uid()
1221 || uid == TransferSyntax::Htj2kLosslessRpcl.uid() =>
1222 {
1223 Some(
1224 options
1225 .htj2k_decoder
1226 .clone()
1227 .or_else(|| auto_htj2k_decoder_template(runner))
1228 .as_ref()
1229 .map(|template| PixelDecoder::Htj2k {
1230 template: template.clone(),
1231 })
1232 .unwrap_or(PixelDecoder::Htj2kUnconfigured),
1233 )
1234 }
1235 _ => None,
1236 }
1237}
1238
1239struct PixelFragmentDecode<'a, R: ValidationCommandRunner> {
1240 file_idx: usize,
1241 frame_idx: usize,
1242 fragment: &'a [u8],
1243 file: &'a PathBuf,
1244 runner: &'a R,
1245 temp_dir: &'a Path,
1246 strict: bool,
1247 timeout: Duration,
1248 max_output_bytes: usize,
1249 max_decoded_bytes: usize,
1250 expected: DecodedFrameExpectation,
1251}
1252
1253fn run_pixel_decoder_for_fragment<R: ValidationCommandRunner>(
1254 decoder: &PixelDecoder,
1255 request: PixelFragmentDecode<'_, R>,
1256) -> ValidationCheck {
1257 let input = request.temp_dir.join(format!(
1258 "file-{:04}-frame-{:06}.codestream",
1259 request.file_idx, request.frame_idx
1260 ));
1261 let output = request.temp_dir.join(format!(
1262 "file-{:04}-frame-{:06}.ppm",
1263 request.file_idx, request.frame_idx
1264 ));
1265 if let Err(err) = write_private_validation_file(&input, request.fragment) {
1266 return failed_check(
1267 "pixel-decode",
1268 Some(request.file),
1269 format!("failed to write temporary codestream: {err}"),
1270 );
1271 }
1272
1273 let check = match decoder {
1274 PixelDecoder::Djpeg => run_named_command_check(
1275 request.runner,
1276 CommandCheckRequest {
1277 check_name: "pixel-djpeg",
1278 command_name: "djpeg",
1279 args: vec![
1280 OsString::from("-outfile"),
1281 output.as_os_str().to_os_string(),
1282 input.as_os_str().to_os_string(),
1283 ],
1284 path: Some(request.file),
1285 required: request.strict,
1286 error_line_is_failure: false,
1287 timeout: request.timeout,
1288 max_output_bytes: request.max_output_bytes,
1289 },
1290 ),
1291 PixelDecoder::OpenJpeg => run_named_command_check(
1292 request.runner,
1293 CommandCheckRequest {
1294 check_name: "pixel-opj-decompress",
1295 command_name: "opj_decompress",
1296 args: vec![
1297 OsString::from("-i"),
1298 input.as_os_str().to_os_string(),
1299 OsString::from("-o"),
1300 output.as_os_str().to_os_string(),
1301 ],
1302 path: Some(request.file),
1303 required: request.strict,
1304 error_line_is_failure: false,
1305 timeout: request.timeout,
1306 max_output_bytes: request.max_output_bytes,
1307 },
1308 ),
1309 PixelDecoder::Htj2k { template } => {
1310 let (command, args) = match htj2k_decoder_command(template, &input, &output) {
1311 Ok(command) => command,
1312 Err(message) => {
1313 return failed_check("pixel-htj2k", Some(request.file), message);
1314 }
1315 };
1316 run_named_command_check(
1317 request.runner,
1318 CommandCheckRequest {
1319 check_name: "pixel-htj2k",
1320 command_name: &command,
1321 args,
1322 path: Some(request.file),
1323 required: request.strict,
1324 error_line_is_failure: false,
1325 timeout: request.timeout,
1326 max_output_bytes: request.max_output_bytes,
1327 },
1328 )
1329 }
1330 PixelDecoder::Htj2kUnconfigured => skipped_check(
1331 "pixel-htj2k",
1332 Some(request.file),
1333 "HTJ2K decoder command is not configured".to_string(),
1334 ),
1335 };
1336 validate_decoded_output(check, &output, request.expected, request.max_decoded_bytes)
1337}
1338
1339fn validate_decoded_output(
1340 mut check: ValidationCheck,
1341 output: &Path,
1342 expected: DecodedFrameExpectation,
1343 max_decoded_bytes: usize,
1344) -> ValidationCheck {
1345 if check.status != ValidationStatus::Passed {
1346 return check;
1347 }
1348 if let Err(message) = inspect_pnm_output(output, expected, max_decoded_bytes) {
1349 check.status = ValidationStatus::Failed;
1350 check.message = message;
1351 }
1352 check
1353}
1354
1355fn inspect_pnm_output(
1356 output: &Path,
1357 expected: DecodedFrameExpectation,
1358 max_decoded_bytes: usize,
1359) -> Result<(), String> {
1360 let mut file = fs::File::open(output).map_err(|err| {
1361 format!(
1362 "decoder did not create readable output {}: {err}",
1363 output.display()
1364 )
1365 })?;
1366 let file_len = file
1367 .metadata()
1368 .map_err(|err| format!("inspect decoder output {}: {err}", output.display()))?
1369 .len();
1370 let max_decoded_bytes = u64::try_from(max_decoded_bytes).unwrap_or(u64::MAX);
1371 if file_len > max_decoded_bytes {
1372 return Err(format!(
1373 "decoder output {} exceeds {max_decoded_bytes} byte validation limit",
1374 output.display()
1375 ));
1376 }
1377
1378 let magic = read_pnm_token(&mut file)?;
1379 let components = match magic.as_str() {
1380 "P5" => 1u64,
1381 "P6" => 3u64,
1382 _ => {
1383 return Err(format!(
1384 "decoder output uses unsupported PNM magic {magic:?}"
1385 ))
1386 }
1387 };
1388 let columns = parse_pnm_u32(&mut file, "width")?;
1389 let rows = parse_pnm_u32(&mut file, "height")?;
1390 let max_value = parse_pnm_u32(&mut file, "maximum sample value")?;
1391 if columns != expected.columns || rows != expected.rows {
1392 return Err(format!(
1393 "decoder output dimensions {columns}x{rows} do not match DICOM {}x{}",
1394 expected.columns, expected.rows
1395 ));
1396 }
1397 if let Some(samples_per_pixel) = expected.samples_per_pixel {
1398 if u64::from(samples_per_pixel) != components {
1399 return Err(format!(
1400 "decoder output has {components} component(s), expected {samples_per_pixel}"
1401 ));
1402 }
1403 }
1404 if !matches!(max_value, 255 | 65_535) {
1405 return Err(format!(
1406 "decoder output maximum sample value {max_value} is unsupported"
1407 ));
1408 }
1409 if let Some(bits_allocated) = expected.bits_allocated {
1410 let expected_max = match bits_allocated {
1411 8 => 255,
1412 16 => 65_535,
1413 other => {
1414 return Err(format!(
1415 "DICOM Bits Allocated {other} is unsupported for PNM validation"
1416 ));
1417 }
1418 };
1419 if max_value != expected_max {
1420 return Err(format!(
1421 "decoder output maximum sample value {max_value} does not match {bits_allocated}-bit DICOM pixels"
1422 ));
1423 }
1424 }
1425 let bytes_per_sample = if max_value > 255 { 2u64 } else { 1u64 };
1426 let payload_len = u64::from(columns)
1427 .checked_mul(u64::from(rows))
1428 .and_then(|value| value.checked_mul(components))
1429 .and_then(|value| value.checked_mul(bytes_per_sample))
1430 .ok_or_else(|| "decoder output dimensions overflow payload length".to_string())?;
1431 let payload_start = file
1432 .stream_position()
1433 .map_err(|err| format!("inspect decoder output payload: {err}"))?;
1434 let expected_file_len = payload_start
1435 .checked_add(payload_len)
1436 .ok_or_else(|| "decoder output length overflows u64".to_string())?;
1437 if file_len != expected_file_len {
1438 return Err(format!(
1439 "decoder output payload has {} bytes, expected {payload_len}",
1440 file_len.saturating_sub(payload_start)
1441 ));
1442 }
1443 Ok(())
1444}
1445
1446fn parse_pnm_u32(file: &mut fs::File, field: &str) -> Result<u32, String> {
1447 let token = read_pnm_token(file)?;
1448 token
1449 .parse::<u32>()
1450 .map_err(|err| format!("decoder output has invalid PNM {field} {token:?}: {err}"))
1451}
1452
1453fn read_pnm_token(file: &mut fs::File) -> Result<String, String> {
1454 let mut token = Vec::new();
1455 let mut in_comment = false;
1456 loop {
1457 let mut byte = [0u8; 1];
1458 if file
1459 .read(&mut byte)
1460 .map_err(|err| format!("read decoder PNM header: {err}"))?
1461 == 0
1462 {
1463 if token.is_empty() {
1464 return Err("decoder output ended inside the PNM header".to_string());
1465 }
1466 break;
1467 }
1468 let byte = byte[0];
1469 if in_comment {
1470 if byte == b'\n' {
1471 in_comment = false;
1472 }
1473 continue;
1474 }
1475 if token.is_empty() && byte == b'#' {
1476 in_comment = true;
1477 continue;
1478 }
1479 if byte.is_ascii_whitespace() {
1480 if token.is_empty() {
1481 continue;
1482 }
1483 break;
1484 }
1485 token.push(byte);
1486 if token.len() > 64 {
1487 return Err("decoder output PNM header token exceeds 64 bytes".to_string());
1488 }
1489 }
1490 String::from_utf8(token).map_err(|err| format!("decoder output PNM header is not ASCII: {err}"))
1491}
1492
1493pub(crate) fn htj2k_decoder_command(
1494 template: &str,
1495 input: &Path,
1496 output: &Path,
1497) -> Result<(String, Vec<OsString>), String> {
1498 let mut parts = shlex::split(template)
1499 .ok_or_else(|| "HTJ2K decoder command has invalid quoting".to_string())?;
1500 if parts.is_empty() {
1501 return Err("HTJ2K decoder command is empty".to_string());
1502 }
1503 let command = parts.remove(0);
1504 if command.trim().is_empty() {
1505 return Err("HTJ2K decoder command is empty".to_string());
1506 }
1507 if !Path::new(&command).is_absolute() {
1508 return Err(
1509 "HTJ2K decoder command must start with an absolute executable path".to_string(),
1510 );
1511 }
1512 let mut saw_placeholder = false;
1513 let mut args = parts
1514 .into_iter()
1515 .map(|part| {
1516 let replaced = part
1517 .replace("{input}", &input.to_string_lossy())
1518 .replace("{output}", &output.to_string_lossy());
1519 if replaced != part {
1520 saw_placeholder = true;
1521 }
1522 OsString::from(replaced)
1523 })
1524 .collect::<Vec<_>>();
1525 if !saw_placeholder {
1526 args.push(input.as_os_str().to_os_string());
1527 }
1528 Ok((command, args))
1529}
1530
1531#[cfg(any(test, feature = "bench-internals"))]
1532pub(crate) fn fragment_payload_without_padding(fragment: &[u8]) -> &[u8] {
1533 fragment
1534}
1535
1536fn failed_check(name: &str, path: Option<&PathBuf>, message: String) -> ValidationCheck {
1537 ValidationCheck {
1538 name: name.to_string(),
1539 path: path.cloned(),
1540 status: ValidationStatus::Failed,
1541 command: Vec::new(),
1542 message,
1543 stdout: String::new(),
1544 stderr: String::new(),
1545 }
1546}
1547
1548fn skipped_check(name: &str, path: Option<&PathBuf>, message: String) -> ValidationCheck {
1549 ValidationCheck {
1550 name: name.to_string(),
1551 path: path.cloned(),
1552 status: ValidationStatus::Skipped,
1553 command: Vec::new(),
1554 message,
1555 stdout: String::new(),
1556 stderr: String::new(),
1557 }
1558}
1559
1560fn write_private_validation_file(path: &Path, bytes: &[u8]) -> Result<(), Error> {
1561 let mut options = std::fs::OpenOptions::new();
1562 options.write(true).create_new(true);
1563 #[cfg(unix)]
1564 {
1565 use std::os::unix::fs::OpenOptionsExt;
1566 options.mode(0o600);
1567 }
1568 let mut file = options.open(path).map_err(|source| Error::Io {
1569 path: path.to_path_buf(),
1570 source,
1571 })?;
1572 file.write_all(bytes).map_err(|source| Error::Io {
1573 path: path.to_path_buf(),
1574 source,
1575 })?;
1576 file.sync_all().map_err(|source| Error::Io {
1577 path: path.to_path_buf(),
1578 source,
1579 })
1580}
1581
1582struct ValidationTempDir {
1583 inner: tempfile::TempDir,
1584}
1585
1586impl ValidationTempDir {
1587 fn create() -> Result<Self, Error> {
1588 let inner = tempfile::Builder::new()
1589 .prefix("wsi-dicom-validation-")
1590 .tempdir()
1591 .map_err(|source| Error::Io {
1592 path: std::env::temp_dir(),
1593 source,
1594 })?;
1595 set_private_validation_dir_permissions(inner.path())?;
1596 Ok(Self { inner })
1597 }
1598
1599 fn path(&self) -> &Path {
1600 self.inner.path()
1601 }
1602}
1603
1604#[cfg(unix)]
1605fn set_private_validation_dir_permissions(path: &Path) -> Result<(), Error> {
1606 use std::os::unix::fs::PermissionsExt;
1607 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
1608 Error::Io {
1609 path: path.to_path_buf(),
1610 source,
1611 }
1612 })
1613}
1614
1615#[cfg(not(unix))]
1616fn set_private_validation_dir_permissions(_path: &Path) -> Result<(), Error> {
1617 Ok(())
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622 use super::{
1623 doctor_dicom_environment_with_runner, validate_dicom_path_with_runner, CommandOutcome,
1624 DoctorOptions, DoctorStatus, SystemCommandRunner, ValidationCommandRunner,
1625 ValidationOptions, ValidationStatus,
1626 };
1627 use dicom_core::{DataElement, PrimitiveValue, VR};
1628 use dicom_dictionary_std::tags;
1629 use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
1630 use std::collections::{BTreeMap, BTreeSet};
1631 use std::ffi::OsString;
1632 use std::fs::File;
1633 use std::io::{BufWriter, Write};
1634 use std::path::{Path, PathBuf};
1635 use std::time::Duration;
1636
1637 #[cfg(not(windows))]
1638 const ABSOLUTE_HTJ2K_DECODER: &str = "/usr/local/bin/ojph_expand";
1639 #[cfg(windows)]
1640 const ABSOLUTE_HTJ2K_DECODER: &str = "C:/Tools/ojph_expand.exe";
1641 #[cfg(not(windows))]
1642 const ABSOLUTE_GROK_DECODER: &str = "/usr/local/bin/grk_decompress";
1643 #[cfg(windows)]
1644 const ABSOLUTE_GROK_DECODER: &str = "C:/Tools/grk_decompress.exe";
1645
1646 #[derive(Default)]
1647 struct FakeRunner {
1648 commands: BTreeSet<String>,
1649 command_paths: BTreeMap<String, PathBuf>,
1650 outcomes: BTreeMap<String, CommandOutcome>,
1651 }
1652
1653 impl FakeRunner {
1654 fn with_command(mut self, name: &str) -> Self {
1655 self.commands.insert(name.to_string());
1656 self
1657 }
1658
1659 fn with_command_path(mut self, name: &str, path: &str) -> Self {
1660 self.command_paths
1661 .insert(name.to_string(), PathBuf::from(path));
1662 self.commands.insert(path.to_string());
1663 self
1664 }
1665
1666 fn with_outcome(mut self, command: &str, outcome: CommandOutcome) -> Self {
1667 self.outcomes.insert(command.to_string(), outcome);
1668 self
1669 }
1670 }
1671
1672 impl ValidationCommandRunner for FakeRunner {
1673 fn find_command(&self, name: &str) -> Option<PathBuf> {
1674 if let Some(path) = self.command_paths.get(name) {
1675 return Some(path.clone());
1676 }
1677 self.commands.contains(name).then(|| PathBuf::from(name))
1678 }
1679
1680 fn run(
1681 &self,
1682 program: &Path,
1683 args: &[OsString],
1684 _timeout: Duration,
1685 _max_output_bytes: usize,
1686 ) -> Result<CommandOutcome, std::io::Error> {
1687 let mut key = program.display().to_string();
1688 for arg in args {
1689 key.push(' ');
1690 key.push_str(&arg.to_string_lossy());
1691 }
1692 let outcome = self.outcomes.get(&key).cloned().unwrap_or(CommandOutcome {
1693 success: true,
1694 timed_out: false,
1695 stdout: String::new(),
1696 stderr: String::new(),
1697 stdout_truncated: false,
1698 stderr_truncated: false,
1699 });
1700 if outcome.success {
1701 for pair in args.windows(2) {
1702 if matches!(pair[0].to_str(), Some("-o" | "-outfile")) {
1703 std::fs::write(PathBuf::from(&pair[1]), b"P6\n1 1\n255\n\x00\x00\x00")?;
1704 }
1705 }
1706 }
1707 Ok(outcome)
1708 }
1709 }
1710
1711 #[cfg(unix)]
1712 #[test]
1713 fn system_runner_drains_stdout_while_waiting_for_child_exit() {
1714 let runner = SystemCommandRunner;
1715 let outcome = runner
1716 .run(
1717 Path::new("/bin/sh"),
1718 &[
1719 OsString::from("-c"),
1720 OsString::from("yes validation-output | head -c 200000"),
1721 ],
1722 Duration::from_secs(5),
1723 4 * 1024 * 1024,
1724 )
1725 .unwrap();
1726
1727 assert!(outcome.success);
1728 assert_eq!(outcome.stdout.len(), 200_000);
1729 assert!(!outcome.timed_out);
1730 }
1731
1732 #[cfg(unix)]
1733 #[test]
1734 fn system_runner_timeout_terminates_descendants_and_returns_promptly() {
1735 let runner = SystemCommandRunner;
1736 let started = std::time::Instant::now();
1737 let outcome = runner
1738 .run(
1739 Path::new("/bin/sh"),
1740 &[OsString::from("-c"), OsString::from("sleep 30 & wait")],
1741 Duration::from_millis(100),
1742 1024,
1743 )
1744 .unwrap();
1745
1746 assert!(outcome.timed_out);
1747 assert!(started.elapsed() < Duration::from_secs(3));
1748 }
1749
1750 #[test]
1751 fn validation_discovers_dicom_files_recursively() {
1752 let tmp = tempfile::tempdir().expect("tempdir");
1753 let nested = tmp.path().join("nested");
1754 std::fs::create_dir(&nested).expect("create nested");
1755 let first = tmp.path().join("one.dcm");
1756 let second = nested.join("two.DCM");
1757 std::fs::write(&first, b"not parsed without pixel checks").expect("write first");
1758 std::fs::write(&second, b"not parsed without pixel checks").expect("write second");
1759 std::fs::write(tmp.path().join("notes.txt"), b"ignore").expect("write ignored");
1760
1761 let report = validate_dicom_path_with_runner(
1762 tmp.path(),
1763 &ValidationOptions {
1764 max_pixel_frames: 0,
1765 ..ValidationOptions::default()
1766 },
1767 &FakeRunner::default(),
1768 )
1769 .expect("validation report");
1770
1771 assert_eq!(report.files, vec![second, first]);
1772 }
1773
1774 #[test]
1775 fn validation_enforces_file_and_depth_limits() {
1776 let tmp = tempfile::tempdir().expect("tempdir");
1777 let nested = tmp.path().join("nested");
1778 std::fs::create_dir(&nested).expect("create nested");
1779 std::fs::write(tmp.path().join("one.dcm"), b"one").expect("write one");
1780 std::fs::write(nested.join("two.dcm"), b"two").expect("write two");
1781
1782 let err = validate_dicom_path_with_runner(
1783 tmp.path(),
1784 &ValidationOptions {
1785 max_pixel_frames: 0,
1786 max_files: 1,
1787 ..ValidationOptions::default()
1788 },
1789 &FakeRunner::default(),
1790 )
1791 .unwrap_err();
1792 assert!(err.to_string().contains("max_files"));
1793
1794 let err = validate_dicom_path_with_runner(
1795 tmp.path(),
1796 &ValidationOptions {
1797 max_pixel_frames: 0,
1798 max_depth: 0,
1799 ..ValidationOptions::default()
1800 },
1801 &FakeRunner::default(),
1802 )
1803 .unwrap_err();
1804 assert!(err.to_string().contains("max_depth"));
1805 }
1806
1807 #[cfg(unix)]
1808 #[test]
1809 fn validation_refuses_symlink_traversal() {
1810 let tmp = tempfile::tempdir().expect("tempdir");
1811 let target = tmp.path().join("target");
1812 std::fs::create_dir(&target).expect("create target");
1813 std::fs::write(target.join("one.dcm"), b"one").expect("write one");
1814 std::os::unix::fs::symlink(&target, tmp.path().join("link")).expect("symlink");
1815
1816 let err = validate_dicom_path_with_runner(
1817 tmp.path(),
1818 &ValidationOptions {
1819 max_pixel_frames: 0,
1820 ..ValidationOptions::default()
1821 },
1822 &FakeRunner::default(),
1823 )
1824 .unwrap_err();
1825 assert!(err.to_string().contains("symlink"));
1826 }
1827
1828 #[test]
1829 fn missing_tools_are_skipped_by_default() {
1830 let tmp = tempfile::tempdir().expect("tempdir");
1831 let file = tmp.path().join("one.dcm");
1832 std::fs::write(&file, b"not parsed without pixel checks").expect("write file");
1833
1834 let report = validate_dicom_path_with_runner(
1835 &file,
1836 &ValidationOptions {
1837 max_pixel_frames: 0,
1838 ..ValidationOptions::default()
1839 },
1840 &FakeRunner::default(),
1841 )
1842 .expect("validation report");
1843
1844 assert!(report
1845 .checks
1846 .iter()
1847 .any(|check| check.name == "dciodvfy" && check.status == ValidationStatus::Skipped));
1848 assert!(!report.has_failures());
1849 }
1850
1851 #[test]
1852 fn strict_mode_fails_missing_required_tools() {
1853 let tmp = tempfile::tempdir().expect("tempdir");
1854 let file = tmp.path().join("one.dcm");
1855 std::fs::write(&file, b"not parsed without pixel checks").expect("write file");
1856
1857 let report = validate_dicom_path_with_runner(
1858 &file,
1859 &ValidationOptions {
1860 strict: true,
1861 max_pixel_frames: 0,
1862 ..ValidationOptions::default()
1863 },
1864 &FakeRunner::default(),
1865 )
1866 .expect("validation report");
1867
1868 assert!(report
1869 .checks
1870 .iter()
1871 .any(|check| check.name == "dciodvfy" && check.status == ValidationStatus::Failed));
1872 assert!(report.has_failures());
1873 }
1874
1875 #[test]
1876 fn dcentvfy_runs_once_for_the_output_set() {
1877 let tmp = tempfile::tempdir().expect("tempdir");
1878 let first = tmp.path().join("one.dcm");
1879 let second = tmp.path().join("two.dcm");
1880 std::fs::write(&first, b"not parsed without pixel checks").expect("write first");
1881 std::fs::write(&second, b"not parsed without pixel checks").expect("write second");
1882
1883 let report = validate_dicom_path_with_runner(
1884 tmp.path(),
1885 &ValidationOptions {
1886 max_pixel_frames: 0,
1887 ..ValidationOptions::default()
1888 },
1889 &FakeRunner::default().with_command("dcentvfy"),
1890 )
1891 .expect("validation report");
1892
1893 let set_checks = report
1894 .checks
1895 .iter()
1896 .filter(|check| check.name == "dcentvfy")
1897 .count();
1898
1899 assert_eq!(set_checks, 1);
1900 }
1901
1902 #[test]
1903 fn set_level_validators_are_chunked_and_preserve_failures() {
1904 let tmp = tempfile::tempdir().expect("tempdir");
1905 for idx in 0..=super::VALIDATOR_SET_FILE_CHUNK_SIZE {
1906 std::fs::write(
1907 tmp.path().join(format!("file-{idx:04}.dcm")),
1908 b"not parsed without pixel checks",
1909 )
1910 .expect("write DICOM placeholder");
1911 }
1912 let failing_file = tmp.path().join(format!(
1913 "file-{:04}.dcm",
1914 super::VALIDATOR_SET_FILE_CHUNK_SIZE
1915 ));
1916 let failing_key = format!("dcentvfy {}", failing_file.display());
1917 let runner = FakeRunner::default().with_command("dcentvfy").with_outcome(
1918 &failing_key,
1919 CommandOutcome {
1920 success: false,
1921 timed_out: false,
1922 stdout: String::new(),
1923 stderr: "set check failed".to_string(),
1924 stdout_truncated: false,
1925 stderr_truncated: false,
1926 },
1927 );
1928
1929 let report = validate_dicom_path_with_runner(
1930 tmp.path(),
1931 &ValidationOptions {
1932 max_pixel_frames: 0,
1933 ..ValidationOptions::default()
1934 },
1935 &runner,
1936 )
1937 .expect("validation report");
1938
1939 let dcentvfy_checks = report
1940 .checks
1941 .iter()
1942 .filter(|check| check.name == "dcentvfy")
1943 .collect::<Vec<_>>();
1944 assert_eq!(dcentvfy_checks.len(), 2);
1945 assert!(dcentvfy_checks
1946 .iter()
1947 .all(|check| check.command.len() <= super::VALIDATOR_SET_FILE_CHUNK_SIZE + 1));
1948 assert!(dcentvfy_checks
1949 .iter()
1950 .any(|check| check.status == ValidationStatus::Failed));
1951 assert!(report.has_failures());
1952 }
1953
1954 #[test]
1955 fn jpeg_baseline_pixel_decode_uses_djpeg() {
1956 let tmp = tempfile::tempdir().expect("tempdir");
1957 let file = tmp.path().join("jpeg.dcm");
1958 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
1959
1960 let report = validate_dicom_path_with_runner(
1961 &file,
1962 &ValidationOptions::default(),
1963 &FakeRunner::default().with_command("djpeg"),
1964 )
1965 .expect("validation report");
1966
1967 assert!(report.checks.iter().any(|check| {
1968 check.name == "pixel-djpeg" && check.status == ValidationStatus::Passed
1969 }));
1970 }
1971
1972 #[test]
1973 fn jpeg2000_pixel_decode_uses_openjpeg() {
1974 let tmp = tempfile::tempdir().expect("tempdir");
1975 let file = tmp.path().join("j2k.dcm");
1976 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.90", &[0xFF, 0x4F, 0xFF, 0x51]);
1977
1978 let report = validate_dicom_path_with_runner(
1979 &file,
1980 &ValidationOptions::default(),
1981 &FakeRunner::default().with_command("opj_decompress"),
1982 )
1983 .expect("validation report");
1984
1985 assert!(report.checks.iter().any(|check| {
1986 check.name == "pixel-opj-decompress" && check.status == ValidationStatus::Passed
1987 }));
1988 }
1989
1990 #[test]
1991 fn htj2k_pixel_decode_uses_auto_grok_decoder_when_available() {
1992 let tmp = tempfile::tempdir().expect("tempdir");
1993 let file = tmp.path().join("htj2k.dcm");
1994 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
1995
1996 let report = validate_dicom_path_with_runner(
1997 &file,
1998 &ValidationOptions::default(),
1999 &FakeRunner::default()
2000 .with_command_path(super::AUTO_HTJ2K_DECODER_COMMAND, ABSOLUTE_GROK_DECODER),
2001 )
2002 .expect("validation report");
2003
2004 assert!(report.checks.iter().any(|check| {
2005 check.name == "pixel-htj2k"
2006 && check.status == ValidationStatus::Passed
2007 && check
2008 .command
2009 .first()
2010 .is_some_and(|command| command == ABSOLUTE_GROK_DECODER)
2011 }));
2012 }
2013
2014 #[test]
2015 fn htj2k_pixel_decode_skips_without_configured_or_auto_decoder() {
2016 let tmp = tempfile::tempdir().expect("tempdir");
2017 let file = tmp.path().join("htj2k.dcm");
2018 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
2019
2020 let report = validate_dicom_path_with_runner(
2021 &file,
2022 &ValidationOptions::default(),
2023 &FakeRunner::default(),
2024 )
2025 .expect("validation report");
2026
2027 assert!(report.checks.iter().any(|check| {
2028 check.name == "pixel-htj2k" && check.status == ValidationStatus::Skipped
2029 }));
2030 }
2031
2032 #[test]
2033 fn strict_htj2k_pixel_decode_fails_without_configured_or_auto_decoder() {
2034 let tmp = tempfile::tempdir().expect("tempdir");
2035 let file = tmp.path().join("htj2k.dcm");
2036 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
2037
2038 let report = validate_dicom_path_with_runner(
2039 &file,
2040 &ValidationOptions {
2041 strict: true,
2042 ..ValidationOptions::default()
2043 },
2044 &FakeRunner::default(),
2045 )
2046 .expect("validation report");
2047
2048 assert!(report.checks.iter().any(|check| {
2049 check.name == "pixel-htj2k" && check.status == ValidationStatus::Failed
2050 }));
2051 assert!(report.has_failures());
2052 }
2053
2054 #[test]
2055 fn strict_mode_fails_missing_pixel_decoder_for_encountered_transfer_syntax() {
2056 let tmp = tempfile::tempdir().expect("tempdir");
2057 let file = tmp.path().join("jpeg.dcm");
2058 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
2059
2060 let report = validate_dicom_path_with_runner(
2061 &file,
2062 &ValidationOptions {
2063 strict: true,
2064 ..ValidationOptions::default()
2065 },
2066 &FakeRunner::default(),
2067 )
2068 .expect("validation report");
2069
2070 assert!(report.checks.iter().any(|check| {
2071 check.name == "pixel-djpeg" && check.status == ValidationStatus::Failed
2072 }));
2073 }
2074
2075 #[test]
2076 fn zero_pixel_frame_limit_disables_pixel_decode_checks() {
2077 let tmp = tempfile::tempdir().expect("tempdir");
2078 let file = tmp.path().join("jpeg.dcm");
2079 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
2080
2081 let report = validate_dicom_path_with_runner(
2082 &file,
2083 &ValidationOptions {
2084 max_pixel_frames: 0,
2085 ..ValidationOptions::default()
2086 },
2087 &FakeRunner::default().with_command("djpeg"),
2088 )
2089 .expect("validation report");
2090
2091 assert!(!report
2092 .checks
2093 .iter()
2094 .any(|check| check.name.starts_with("pixel-")));
2095 }
2096
2097 #[test]
2098 fn uncompressed_transfer_syntax_skips_pixel_decode() {
2099 let tmp = tempfile::tempdir().expect("tempdir");
2100 let file = tmp.path().join("explicit.dcm");
2101 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.1", &[1, 2, 3, 4]);
2102
2103 let report = validate_dicom_path_with_runner(
2104 &file,
2105 &ValidationOptions::default(),
2106 &FakeRunner::default(),
2107 )
2108 .expect("validation report");
2109
2110 assert!(report.checks.iter().any(|check| {
2111 check.name == "pixel-decode" && check.status == ValidationStatus::Skipped
2112 }));
2113 }
2114
2115 #[test]
2116 fn htj2k_decoder_template_preserves_quoted_arguments() {
2117 let input = Path::new("/tmp/input codestream.j2k");
2118 let output = Path::new("/tmp/output pixels.ppm");
2119 let template =
2120 format!("{ABSOLUTE_HTJ2K_DECODER} --codec \"Open JPH\" -i {{input}} -o {{output}}");
2121
2122 let (command, args) =
2123 super::htj2k_decoder_command(&template, input, output).expect("parse decoder command");
2124
2125 assert_eq!(command, ABSOLUTE_HTJ2K_DECODER);
2126 assert_eq!(
2127 args,
2128 vec![
2129 OsString::from("--codec"),
2130 OsString::from("Open JPH"),
2131 OsString::from("-i"),
2132 input.as_os_str().to_os_string(),
2133 OsString::from("-o"),
2134 output.as_os_str().to_os_string(),
2135 ]
2136 );
2137 }
2138
2139 #[test]
2140 fn htj2k_decoder_template_rejects_bare_command_name() {
2141 let err = super::htj2k_decoder_command(
2142 "ojph_expand -i {input} -o {output}",
2143 Path::new("/tmp/input.jhc"),
2144 Path::new("/tmp/output.ppm"),
2145 )
2146 .unwrap_err();
2147
2148 assert!(err.contains("absolute executable path"));
2149 }
2150
2151 #[test]
2152 fn empty_htj2k_decoder_template_is_reported_as_configuration_failure() {
2153 let tmp = tempfile::tempdir().expect("tempdir");
2154 let file = tmp.path().join("htj2k.dcm");
2155 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
2156
2157 let report = validate_dicom_path_with_runner(
2158 &file,
2159 &ValidationOptions {
2160 htj2k_decoder: Some(" ".to_string()),
2161 ..ValidationOptions::default()
2162 },
2163 &FakeRunner::default(),
2164 )
2165 .expect("validation report");
2166
2167 assert!(report.checks.iter().any(|check| {
2168 check.name == "pixel-htj2k"
2169 && check.status == ValidationStatus::Failed
2170 && check.message.contains("HTJ2K decoder command is empty")
2171 }));
2172 }
2173
2174 #[test]
2175 fn bare_htj2k_decoder_template_is_reported_as_configuration_failure() {
2176 let tmp = tempfile::tempdir().expect("tempdir");
2177 let file = tmp.path().join("htj2k.dcm");
2178 write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
2179
2180 let report = validate_dicom_path_with_runner(
2181 &file,
2182 &ValidationOptions {
2183 htj2k_decoder: Some("ojph_expand -i {input} -o {output}".to_string()),
2184 ..ValidationOptions::default()
2185 },
2186 &FakeRunner::default(),
2187 )
2188 .expect("validation report");
2189
2190 assert!(report.checks.iter().any(|check| {
2191 check.name == "pixel-htj2k"
2192 && check.status == ValidationStatus::Failed
2193 && check.message.contains("absolute executable path")
2194 }));
2195 }
2196
2197 #[test]
2198 fn fragment_payload_ending_in_zero_is_preserved_for_validation() {
2199 assert_eq!(
2200 super::fragment_payload_without_padding(&[0xFF, 0x4F, 0x00]),
2201 &[0xFF, 0x4F, 0x00]
2202 );
2203 assert_eq!(
2204 super::fragment_payload_without_padding(&[0xFF, 0x4F, 0x00, 0x00]),
2205 &[0xFF, 0x4F, 0x00, 0x00]
2206 );
2207 }
2208
2209 #[test]
2210 fn encapsulated_frame_assembly_uses_offsets_and_extended_lengths() {
2211 let sequence = dicom_core::value::PixelFragmentSequence::new_fragments(vec![
2212 vec![1, 2],
2213 vec![3, 0],
2214 vec![4, 5],
2215 ]);
2216 let frames =
2217 super::assemble_encapsulated_frames(&sequence, 2, Some(&[0, 20]), Some(&[3, 2]), 2, 64)
2218 .unwrap();
2219
2220 assert_eq!(frames, vec![vec![1, 2, 3], vec![4, 5]]);
2221 }
2222
2223 #[test]
2224 fn encapsulated_frame_assembly_rejects_ambiguous_fragment_mapping() {
2225 let sequence = dicom_core::value::PixelFragmentSequence::new_fragments(vec![
2226 vec![1],
2227 vec![2],
2228 vec![3],
2229 ]);
2230 let error = super::assemble_encapsulated_frames(&sequence, 2, None, None, 2, 64)
2231 .expect_err("multiple frames without offsets must be unambiguous");
2232 assert!(error.contains("without an offset table"));
2233 }
2234
2235 #[test]
2236 fn decoded_output_must_exist_and_match_dicom_geometry() {
2237 let tmp = tempfile::tempdir().unwrap();
2238 let output = tmp.path().join("frame.ppm");
2239 let expected = super::DecodedFrameExpectation {
2240 columns: 2,
2241 rows: 1,
2242 samples_per_pixel: Some(3),
2243 bits_allocated: Some(8),
2244 };
2245
2246 let missing = super::inspect_pnm_output(&output, expected, 1024)
2247 .expect_err("missing output must fail");
2248 assert!(missing.contains("did not create readable output"));
2249
2250 std::fs::write(&output, b"P6\n2 1\n255\n\x01\x02\x03\x04\x05\x06").unwrap();
2251 super::inspect_pnm_output(&output, expected, 1024).unwrap();
2252
2253 std::fs::write(&output, b"P6\n1 1\n255\n\x01\x02\x03").unwrap();
2254 let wrong_geometry = super::inspect_pnm_output(&output, expected, 1024)
2255 .expect_err("wrong dimensions must fail");
2256 assert!(wrong_geometry.contains("do not match DICOM"));
2257 }
2258
2259 #[cfg(unix)]
2260 #[test]
2261 fn validation_temp_dir_and_codestream_files_are_private() {
2262 use std::os::unix::fs::PermissionsExt;
2263
2264 let temp_dir = super::ValidationTempDir::create().expect("validation temp dir");
2265 let dir_mode = std::fs::metadata(temp_dir.path())
2266 .expect("temp dir metadata")
2267 .permissions()
2268 .mode()
2269 & 0o777;
2270 assert_eq!(dir_mode, 0o700);
2271
2272 let codestream = temp_dir.path().join("frame.codestream");
2273 super::write_private_validation_file(&codestream, b"codestream").expect("write codestream");
2274 let file_mode = std::fs::metadata(&codestream)
2275 .expect("codestream metadata")
2276 .permissions()
2277 .mode()
2278 & 0o777;
2279 assert_eq!(file_mode, 0o600);
2280 }
2281
2282 #[test]
2283 fn command_timeout_is_reported_as_failed_check() {
2284 let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
2285 "dciodvfy -new one.dcm",
2286 CommandOutcome {
2287 success: false,
2288 timed_out: true,
2289 stdout: String::new(),
2290 stderr: String::new(),
2291 stdout_truncated: false,
2292 stderr_truncated: false,
2293 },
2294 );
2295
2296 let check = super::run_named_command_check(
2297 &runner,
2298 super::CommandCheckRequest {
2299 check_name: "dciodvfy",
2300 command_name: "dciodvfy",
2301 args: vec![OsString::from("-new"), OsString::from("one.dcm")],
2302 path: None,
2303 required: true,
2304 error_line_is_failure: true,
2305 timeout: std::time::Duration::from_millis(25),
2306 max_output_bytes: 4 * 1024 * 1024,
2307 },
2308 );
2309
2310 assert_eq!(check.status, ValidationStatus::Failed);
2311 assert!(check.message.contains("timed out after 25ms"));
2312 }
2313
2314 #[test]
2315 fn command_output_limit_is_reported_as_failed_check() {
2316 let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
2317 "dciodvfy -new one.dcm",
2318 CommandOutcome {
2319 success: true,
2320 timed_out: false,
2321 stdout: "prefix".to_string(),
2322 stderr: String::new(),
2323 stdout_truncated: true,
2324 stderr_truncated: false,
2325 },
2326 );
2327
2328 let check = super::run_named_command_check(
2329 &runner,
2330 super::CommandCheckRequest {
2331 check_name: "dciodvfy",
2332 command_name: "dciodvfy",
2333 args: vec![OsString::from("-new"), OsString::from("one.dcm")],
2334 path: None,
2335 required: true,
2336 error_line_is_failure: true,
2337 timeout: std::time::Duration::from_millis(25),
2338 max_output_bytes: 4,
2339 },
2340 );
2341
2342 assert_eq!(check.status, ValidationStatus::Failed);
2343 assert!(check.message.contains("capture limit"));
2344 }
2345
2346 #[test]
2347 fn validation_options_use_seconds_for_json_and_runtime_timeout() {
2348 let options = ValidationOptions {
2349 strict: true,
2350 dcmvalidate_iod: Some(PathBuf::from("iod.xml")),
2351 htj2k_decoder: Some("ojph_expand -i {input} -o {output}".to_string()),
2352 max_pixel_frames: 3,
2353 command_timeout_secs: 12,
2354 max_files: 100_000,
2355 max_depth: 64,
2356 max_child_output_bytes: 4 * 1024 * 1024,
2357 max_pixel_frame_bytes: 512 * 1024 * 1024,
2358 };
2359
2360 let json = serde_json::to_string(&options).expect("serialize validation options");
2361 assert!(json.contains("\"command_timeout_secs\":12"));
2362
2363 let options: ValidationOptions =
2364 serde_json::from_str(&json).expect("deserialize validation options");
2365 assert!(options.strict);
2366 assert_eq!(options.command_timeout(), Duration::from_secs(12));
2367 assert_eq!(options.max_pixel_frames, 3);
2368 }
2369
2370 #[test]
2371 fn doctor_reports_missing_tools_without_failing_non_strict_runs() {
2372 let report =
2373 doctor_dicom_environment_with_runner(&DoctorOptions::default(), &FakeRunner::default());
2374
2375 assert!(report
2376 .tools
2377 .iter()
2378 .any(|tool| { tool.name == "dciodvfy" && tool.status == DoctorStatus::Missing }));
2379 assert!(!report.has_failures());
2380 }
2381
2382 #[test]
2383 fn doctor_fails_missing_baseline_tools_in_strict_mode() {
2384 let report = doctor_dicom_environment_with_runner(
2385 &DoctorOptions {
2386 strict: true,
2387 ..DoctorOptions::default()
2388 },
2389 &FakeRunner::default(),
2390 );
2391
2392 assert!(report
2393 .tools
2394 .iter()
2395 .any(|tool| { tool.name == "dciodvfy" && tool.status == DoctorStatus::Failed }));
2396 assert!(report.has_failures());
2397 }
2398
2399 #[test]
2400 fn doctor_runs_probe_for_found_commands() {
2401 let report = doctor_dicom_environment_with_runner(
2402 &DoctorOptions::default(),
2403 &FakeRunner::default().with_command("dciodvfy"),
2404 );
2405
2406 let tool = report
2407 .tools
2408 .iter()
2409 .find(|tool| tool.name == "dciodvfy")
2410 .expect("dciodvfy doctor tool");
2411 assert_eq!(tool.status, DoctorStatus::Available);
2412 assert_eq!(tool.command, vec!["dciodvfy", "-version"]);
2413 assert!(tool.message.contains("probe passed"));
2414 }
2415
2416 #[test]
2417 fn doctor_fails_found_command_when_probe_fails() {
2418 let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
2419 "dciodvfy -version",
2420 CommandOutcome {
2421 success: false,
2422 timed_out: false,
2423 stdout: String::new(),
2424 stderr: "bad probe".to_string(),
2425 stdout_truncated: false,
2426 stderr_truncated: false,
2427 },
2428 );
2429
2430 let report = doctor_dicom_environment_with_runner(&DoctorOptions::default(), &runner);
2431
2432 assert!(report.tools.iter().any(|tool| {
2433 tool.name == "dciodvfy"
2434 && tool.status == DoctorStatus::Failed
2435 && tool.message.contains("probe failed")
2436 }));
2437 assert!(report.has_failures());
2438 }
2439
2440 #[test]
2441 fn doctor_accepts_openjpeg_help_output_when_it_exits_nonzero() {
2442 let runner = FakeRunner::default()
2443 .with_command("opj_decompress")
2444 .with_outcome(
2445 "opj_decompress -h",
2446 CommandOutcome {
2447 success: false,
2448 timed_out: false,
2449 stdout: "This is the opj_decompress utility from the OpenJPEG project."
2450 .to_string(),
2451 stderr: String::new(),
2452 stdout_truncated: false,
2453 stderr_truncated: false,
2454 },
2455 );
2456
2457 let report = doctor_dicom_environment_with_runner(&DoctorOptions::default(), &runner);
2458
2459 assert!(report.tools.iter().any(|tool| {
2460 tool.name == "opj_decompress" && tool.status == DoctorStatus::Available
2461 }));
2462 }
2463
2464 #[test]
2465 fn doctor_parses_configured_htj2k_decoder_template() {
2466 let report = doctor_dicom_environment_with_runner(
2467 &DoctorOptions {
2468 htj2k_decoder: Some(format!(
2469 "{ABSOLUTE_HTJ2K_DECODER} -i {{input}} -o {{output}}"
2470 )),
2471 ..DoctorOptions::default()
2472 },
2473 &FakeRunner::default().with_command(ABSOLUTE_HTJ2K_DECODER),
2474 );
2475
2476 assert!(report.tools.iter().any(|tool| {
2477 tool.name == "htj2k_decoder"
2478 && tool.status == DoctorStatus::Available
2479 && tool
2480 .command
2481 .first()
2482 .is_some_and(|command| command == ABSOLUTE_HTJ2K_DECODER)
2483 }));
2484 }
2485
2486 #[test]
2487 fn doctor_auto_detects_grok_for_htj2k_decoder() {
2488 let report = doctor_dicom_environment_with_runner(
2489 &DoctorOptions::default(),
2490 &FakeRunner::default()
2491 .with_command_path(super::AUTO_HTJ2K_DECODER_COMMAND, ABSOLUTE_GROK_DECODER),
2492 );
2493
2494 assert!(report.tools.iter().any(|tool| {
2495 tool.name == "htj2k_decoder"
2496 && tool.status == DoctorStatus::Available
2497 && !tool.required
2498 && tool.path.as_deref() == Some(Path::new(ABSOLUTE_GROK_DECODER))
2499 && tool.message.contains("auto-detected")
2500 }));
2501 }
2502
2503 #[test]
2504 fn doctor_strict_fails_when_htj2k_decoder_is_not_configured_or_auto_detected() {
2505 let report = doctor_dicom_environment_with_runner(
2506 &DoctorOptions {
2507 strict: true,
2508 ..DoctorOptions::default()
2509 },
2510 &FakeRunner::default(),
2511 );
2512
2513 assert!(report.tools.iter().any(|tool| {
2514 tool.name == "htj2k_decoder" && tool.required && tool.status == DoctorStatus::Failed
2515 }));
2516 assert!(report.has_failures());
2517 }
2518
2519 #[test]
2520 fn staged_dicom3tools_probe_requires_debug_build_or_explicit_env() {
2521 assert!(!super::staged_dicom3tools_probe_enabled_from(false, false));
2522 assert!(super::staged_dicom3tools_probe_enabled_from(true, false));
2523 assert!(super::staged_dicom3tools_probe_enabled_from(false, true));
2524 }
2525
2526 fn write_encapsulated_dicom(path: &Path, transfer_syntax: &str, frame: &[u8]) {
2527 let mut object = InMemDicomObject::new_empty();
2528 object.put(DataElement::<InMemDicomObject>::new(
2529 tags::SOP_CLASS_UID,
2530 VR::UI,
2531 PrimitiveValue::from("1.2.840.10008.5.1.4.1.1.77.1.6"),
2532 ));
2533 object.put(DataElement::<InMemDicomObject>::new(
2534 tags::SOP_INSTANCE_UID,
2535 VR::UI,
2536 PrimitiveValue::from("1.2.826.0.1.3680043.10.999.200"),
2537 ));
2538 object.put(DataElement::<InMemDicomObject>::new(
2539 tags::ROWS,
2540 VR::US,
2541 PrimitiveValue::from(1u16),
2542 ));
2543 object.put(DataElement::<InMemDicomObject>::new(
2544 tags::COLUMNS,
2545 VR::US,
2546 PrimitiveValue::from(1u16),
2547 ));
2548 object.put(DataElement::<InMemDicomObject>::new(
2549 tags::NUMBER_OF_FRAMES,
2550 VR::IS,
2551 PrimitiveValue::from("1"),
2552 ));
2553 let meta = FileMetaTableBuilder::new()
2554 .media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.77.1.6")
2555 .media_storage_sop_instance_uid("1.2.826.0.1.3680043.10.999.200")
2556 .transfer_syntax(transfer_syntax);
2557 let file = File::create(path).expect("create DICOM");
2558 let mut output = BufWriter::new(file);
2559 object
2560 .with_meta(meta)
2561 .expect("file meta")
2562 .write_all(&mut output)
2563 .expect("write DICOM object");
2564 crate::writer::write_encapsulated_pixel_data_from_frames(
2565 &mut output,
2566 &[u64::try_from(frame.len()).expect("frame len")],
2567 |_, output| output.write_all(frame),
2568 )
2569 .expect("write pixel data");
2570 output.flush().expect("flush DICOM");
2571 }
2572}