Skip to main content

wsi_dicom/
validation.rs

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/// Options for validating generated DICOM files with external tools.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(default)]
20#[non_exhaustive]
21pub struct ValidationOptions {
22    /// Treat missing required validators or pixel decoders as failures.
23    pub strict: bool,
24    /// Optional dcm4che IOD XML file used by `dcmvalidate`.
25    pub dcmvalidate_iod: Option<PathBuf>,
26    /// Optional HTJ2K decoder command template using `{input}` and `{output}` placeholders.
27    pub htj2k_decoder: Option<String>,
28    /// Maximum number of pixel frames to decode per transfer syntax; zero disables pixel checks.
29    pub max_pixel_frames: usize,
30    /// Timeout in seconds applied to each external validator command.
31    pub command_timeout_secs: u64,
32    /// Maximum DICOM files discovered under a directory input.
33    pub max_files: usize,
34    /// Maximum directory depth walked under a directory input.
35    pub max_depth: usize,
36    /// Maximum captured stdout or stderr bytes per child process.
37    pub max_child_output_bytes: usize,
38    /// Maximum encoded bytes assembled for one compressed pixel frame.
39    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    /// Timeout applied to each external validator command.
60    pub fn command_timeout(&self) -> Duration {
61        Duration::from_secs(self.command_timeout_secs)
62    }
63}
64
65/// Options for checking validator tool availability.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(default)]
68#[non_exhaustive]
69pub struct DoctorOptions {
70    /// Treat missing required tools as failures.
71    pub strict: bool,
72    /// Optional dcm4che IOD XML file used to decide whether `dcmvalidate` is configured.
73    pub dcmvalidate_iod: Option<PathBuf>,
74    /// Optional HTJ2K decoder command template to check.
75    pub htj2k_decoder: Option<String>,
76}
77
78/// Validator environment report.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[non_exhaustive]
81pub struct DoctorReport {
82    /// Tools checked for availability and configuration.
83    pub tools: Vec<DoctorTool>,
84}
85
86impl DoctorReport {
87    /// Whether any configured tool failed its doctor check.
88    pub fn has_failures(&self) -> bool {
89        self.tools
90            .iter()
91            .any(|tool| tool.status == DoctorStatus::Failed)
92    }
93
94    /// Count tools that are available.
95    pub fn available_tools(&self) -> usize {
96        self.tools
97            .iter()
98            .filter(|tool| tool.status == DoctorStatus::Available)
99            .count()
100    }
101
102    /// Count tools that were found but failed their doctor check.
103    pub fn failed_tools(&self) -> usize {
104        self.tools
105            .iter()
106            .filter(|tool| tool.status == DoctorStatus::Failed)
107            .count()
108    }
109
110    /// Count tools that were required but missing.
111    pub fn missing_tools(&self) -> usize {
112        self.tools
113            .iter()
114            .filter(|tool| tool.status == DoctorStatus::Missing)
115            .count()
116    }
117
118    /// Count optional or unconfigured tools skipped by doctor.
119    pub fn skipped_tools(&self) -> usize {
120        self.tools
121            .iter()
122            .filter(|tool| tool.status == DoctorStatus::Skipped)
123            .count()
124    }
125}
126
127/// Status for one validator tool check.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[non_exhaustive]
130pub struct DoctorTool {
131    /// Tool name.
132    pub name: String,
133    /// Whether strict mode treats this tool as required.
134    pub required: bool,
135    /// Availability or configuration status.
136    pub status: DoctorStatus,
137    /// Command used for the doctor probe.
138    pub command: Vec<String>,
139    /// Resolved tool path when available.
140    pub path: Option<PathBuf>,
141    /// Human-readable status message.
142    pub message: String,
143}
144
145/// Availability status for a validator tool.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum DoctorStatus {
150    /// Tool was found and accepted.
151    Available,
152    /// Required tool was not found.
153    Missing,
154    /// Tool was found but failed its probe.
155    Failed,
156    /// Tool was optional or not configured.
157    Skipped,
158}
159
160/// Report from validating one file or directory of DICOM files.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162#[non_exhaustive]
163pub struct ValidationReport {
164    /// Input path passed to validation.
165    pub input: PathBuf,
166    /// DICOM files discovered and checked.
167    pub files: Vec<PathBuf>,
168    /// Individual validation checks.
169    pub checks: Vec<ValidationCheck>,
170}
171
172impl ValidationReport {
173    /// Whether any validation check failed.
174    pub fn has_failures(&self) -> bool {
175        self.checks
176            .iter()
177            .any(|check| check.status == ValidationStatus::Failed)
178    }
179
180    /// Count validation checks that passed.
181    pub fn passed_checks(&self) -> usize {
182        self.checks
183            .iter()
184            .filter(|check| check.status == ValidationStatus::Passed)
185            .count()
186    }
187
188    /// Count validation checks that failed.
189    pub fn failed_checks(&self) -> usize {
190        self.checks
191            .iter()
192            .filter(|check| check.status == ValidationStatus::Failed)
193            .count()
194    }
195
196    /// Count validation checks that were skipped.
197    pub fn skipped_checks(&self) -> usize {
198        self.checks
199            .iter()
200            .filter(|check| check.status == ValidationStatus::Skipped)
201            .count()
202    }
203}
204
205/// Result of one external validator or pixel decode check.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207#[non_exhaustive]
208pub struct ValidationCheck {
209    /// Check name.
210    pub name: String,
211    /// File path associated with this check, when file-specific.
212    pub path: Option<PathBuf>,
213    /// Check status.
214    pub status: ValidationStatus,
215    /// Command used for the check.
216    pub command: Vec<String>,
217    /// Human-readable result message.
218    pub message: String,
219    /// Captured standard output.
220    pub stdout: String,
221    /// Captured standard error.
222    pub stderr: String,
223}
224
225/// Status for one DICOM validation check.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
227#[serde(rename_all = "snake_case")]
228#[non_exhaustive]
229pub enum ValidationStatus {
230    /// Check completed successfully.
231    Passed,
232    /// Check completed and reported a failure.
233    Failed,
234    /// Check was intentionally skipped, usually because a tool was unavailable.
235    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
316/// Validate a DICOM file or recursively discovered DICOM directory.
317pub 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
324/// Check local external DICOM validator availability.
325pub 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}