use std::ffi::OsString;
use std::fs;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use dicom_core::value::{PixelFragmentSequence, Value};
use dicom_dictionary_std::tags;
use serde::{Deserialize, Serialize};
use crate::{Error, TransferSyntax};
mod process;
use process::{CommandOutcome, SystemCommandRunner, ValidationCommandRunner};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct ValidationOptions {
pub strict: bool,
pub dcmvalidate_iod: Option<PathBuf>,
pub htj2k_decoder: Option<String>,
pub max_pixel_frames: usize,
pub command_timeout_secs: u64,
pub max_files: usize,
pub max_depth: usize,
pub max_child_output_bytes: usize,
pub max_pixel_frame_bytes: usize,
}
impl Default for ValidationOptions {
fn default() -> Self {
Self {
strict: false,
dcmvalidate_iod: None,
htj2k_decoder: None,
max_pixel_frames: 1,
command_timeout_secs: 60,
max_files: 100_000,
max_depth: 64,
max_child_output_bytes: 4 * 1024 * 1024,
max_pixel_frame_bytes: 512 * 1024 * 1024,
}
}
}
impl ValidationOptions {
pub fn command_timeout(&self) -> Duration {
Duration::from_secs(self.command_timeout_secs)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct DoctorOptions {
pub strict: bool,
pub dcmvalidate_iod: Option<PathBuf>,
pub htj2k_decoder: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DoctorReport {
pub tools: Vec<DoctorTool>,
}
impl DoctorReport {
pub fn has_failures(&self) -> bool {
self.tools
.iter()
.any(|tool| tool.status == DoctorStatus::Failed)
}
pub fn available_tools(&self) -> usize {
self.tools
.iter()
.filter(|tool| tool.status == DoctorStatus::Available)
.count()
}
pub fn failed_tools(&self) -> usize {
self.tools
.iter()
.filter(|tool| tool.status == DoctorStatus::Failed)
.count()
}
pub fn missing_tools(&self) -> usize {
self.tools
.iter()
.filter(|tool| tool.status == DoctorStatus::Missing)
.count()
}
pub fn skipped_tools(&self) -> usize {
self.tools
.iter()
.filter(|tool| tool.status == DoctorStatus::Skipped)
.count()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DoctorTool {
pub name: String,
pub required: bool,
pub status: DoctorStatus,
pub command: Vec<String>,
pub path: Option<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DoctorStatus {
Available,
Missing,
Failed,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ValidationReport {
pub input: PathBuf,
pub files: Vec<PathBuf>,
pub checks: Vec<ValidationCheck>,
}
impl ValidationReport {
pub fn has_failures(&self) -> bool {
self.checks
.iter()
.any(|check| check.status == ValidationStatus::Failed)
}
pub fn passed_checks(&self) -> usize {
self.checks
.iter()
.filter(|check| check.status == ValidationStatus::Passed)
.count()
}
pub fn failed_checks(&self) -> usize {
self.checks
.iter()
.filter(|check| check.status == ValidationStatus::Failed)
.count()
}
pub fn skipped_checks(&self) -> usize {
self.checks
.iter()
.filter(|check| check.status == ValidationStatus::Skipped)
.count()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ValidationCheck {
pub name: String,
pub path: Option<PathBuf>,
pub status: ValidationStatus,
pub command: Vec<String>,
pub message: String,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValidationStatus {
Passed,
Failed,
Skipped,
}
const DOCTOR_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ValidatorToolSpec {
name: &'static str,
required: bool,
doctor_args: &'static [&'static str],
nonzero_success_output: Option<&'static str>,
}
const DCIODVFY_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "dciodvfy",
required: true,
doctor_args: &["-version"],
nonzero_success_output: None,
};
const DCENTVFY_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "dcentvfy",
required: true,
doctor_args: &["-version"],
nonzero_success_output: None,
};
const VALIDATE_IODS_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "validate_iods",
required: false,
doctor_args: &["-h"],
nonzero_success_output: None,
};
const DJPEG_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "djpeg",
required: false,
doctor_args: &["-version"],
nonzero_success_output: None,
};
const OPJ_DECOMPRESS_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "opj_decompress",
required: false,
doctor_args: &["-h"],
nonzero_success_output: Some("OpenJPEG"),
};
const DCMVALIDATE_TOOL: ValidatorToolSpec = ValidatorToolSpec {
name: "dcmvalidate",
required: true,
doctor_args: &["--help"],
nonzero_success_output: None,
};
const VALIDATOR_DOCTOR_TOOLS: &[ValidatorToolSpec] = &[
DCIODVFY_TOOL,
DCENTVFY_TOOL,
VALIDATE_IODS_TOOL,
DJPEG_TOOL,
OPJ_DECOMPRESS_TOOL,
];
const AUTO_HTJ2K_DECODER_COMMAND: &str = "grk_decompress";
const VALIDATOR_SET_FILE_CHUNK_SIZE: usize = 512;
fn staged_dicom3tools_command(name: &str) -> Option<PathBuf> {
if !staged_dicom3tools_probe_enabled() {
return None;
}
let staged = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("dicom3tools-mac")
.join(name);
staged.is_file().then_some(staged)
}
fn staged_dicom3tools_probe_enabled() -> bool {
staged_dicom3tools_probe_enabled_from(
cfg!(debug_assertions),
std::env::var_os("WSI_DICOM_VALIDATOR_STAGED_TOOLS").is_some(),
)
}
fn staged_dicom3tools_probe_enabled_from(debug_assertions: bool, env_present: bool) -> bool {
debug_assertions || env_present
}
pub fn validate_dicom_path(
path: impl AsRef<Path>,
options: &ValidationOptions,
) -> Result<ValidationReport, Error> {
validate_dicom_path_with_runner(path.as_ref(), options, &SystemCommandRunner)
}
pub fn doctor_dicom_environment(options: &DoctorOptions) -> DoctorReport {
doctor_dicom_environment_with_runner(options, &SystemCommandRunner)
}
pub(crate) fn doctor_dicom_environment_with_runner(
options: &DoctorOptions,
runner: &impl ValidationCommandRunner,
) -> DoctorReport {
let mut tools = VALIDATOR_DOCTOR_TOOLS
.iter()
.map(|tool| doctor_command_tool(runner, tool, options.strict))
.collect::<Vec<_>>();
tools.push(match &options.dcmvalidate_iod {
Some(_iod) => doctor_command_tool(runner, &DCMVALIDATE_TOOL, options.strict),
None => skipped_doctor_tool(
"dcmvalidate",
false,
"dcmvalidate IOD path is not configured".to_string(),
),
});
tools.push(doctor_htj2k_decoder_tool(runner, options));
DoctorReport { tools }
}
fn doctor_command_tool(
runner: &impl ValidationCommandRunner,
tool: &ValidatorToolSpec,
strict: bool,
) -> DoctorTool {
let args = tool
.doctor_args
.iter()
.map(|arg| OsString::from(*arg))
.collect::<Vec<_>>();
let command = std::iter::once(tool.name.to_string())
.chain(tool.doctor_args.iter().map(|arg| (*arg).to_string()))
.collect::<Vec<_>>();
match runner.find_command(tool.name) {
Some(path) => match runner.run(&path, &args, DOCTOR_PROBE_TIMEOUT, 4 * 1024 * 1024) {
Ok(outcome) if doctor_probe_passed(tool, &outcome) => DoctorTool {
name: tool.name.to_string(),
required: tool.required,
status: DoctorStatus::Available,
command,
path: Some(path),
message: format!("{} probe passed", tool.name),
},
Ok(outcome) => {
let message = if outcome.timed_out {
format!(
"{} probe timed out after {}",
tool.name,
format_timeout(DOCTOR_PROBE_TIMEOUT)
)
} else {
format!("{} probe failed", tool.name)
};
DoctorTool {
name: tool.name.to_string(),
required: tool.required,
status: DoctorStatus::Failed,
command,
path: Some(path),
message,
}
}
Err(source) => DoctorTool {
name: tool.name.to_string(),
required: tool.required,
status: DoctorStatus::Failed,
command,
path: Some(path),
message: format!("failed to start {}: {source}", tool.name),
},
},
None => {
let status = if strict && tool.required {
DoctorStatus::Failed
} else {
DoctorStatus::Missing
};
DoctorTool {
name: tool.name.to_string(),
required: tool.required,
status,
command,
path: None,
message: format!("{} not found", tool.name),
}
}
}
}
fn doctor_probe_passed(tool: &ValidatorToolSpec, outcome: &CommandOutcome) -> bool {
!outcome.timed_out
&& (outcome.success
|| tool
.nonzero_success_output
.is_some_and(|needle| output_contains_probe_needle(outcome, needle)))
}
fn output_contains_probe_needle(outcome: &CommandOutcome, needle: &str) -> bool {
outcome.stdout.contains(needle) || outcome.stderr.contains(needle)
}
fn doctor_htj2k_decoder_tool(
runner: &impl ValidationCommandRunner,
options: &DoctorOptions,
) -> DoctorTool {
let configured = options.htj2k_decoder.is_some();
let template = match options
.htj2k_decoder
.clone()
.or_else(|| auto_htj2k_decoder_template(runner))
{
Some(template) => template,
None => {
let status = if options.strict {
DoctorStatus::Failed
} else {
DoctorStatus::Skipped
};
return DoctorTool {
name: "htj2k_decoder".to_string(),
required: options.strict,
status,
command: Vec::new(),
path: None,
message: "HTJ2K decoder command is not configured and grk_decompress was not found"
.to_string(),
};
}
};
let (name, args) =
match htj2k_decoder_command(&template, Path::new("input.jhc"), Path::new("output.ppm")) {
Ok(command) => command,
Err(message) => {
return DoctorTool {
name: "htj2k_decoder".to_string(),
required: options.strict || configured,
status: DoctorStatus::Failed,
command: Vec::new(),
path: None,
message,
};
}
};
let command = std::iter::once(name.clone())
.chain(args.iter().map(|arg| arg.to_string_lossy().into_owned()))
.collect::<Vec<_>>();
match runner.find_command(&name) {
Some(path) => DoctorTool {
name: "htj2k_decoder".to_string(),
required: options.strict || configured,
status: DoctorStatus::Available,
command,
path: Some(path),
message: if configured {
format!("{name} found")
} else {
format!("{name} auto-detected")
},
},
None => DoctorTool {
name: "htj2k_decoder".to_string(),
required: options.strict || configured,
status: DoctorStatus::Failed,
command,
path: None,
message: format!("{name} not found"),
},
}
}
fn auto_htj2k_decoder_template(runner: &impl ValidationCommandRunner) -> Option<String> {
let path = runner.find_command(AUTO_HTJ2K_DECODER_COMMAND)?;
path.is_absolute().then(|| {
format!(
"{} -i {{input}} -o {{output}}",
shlex_quote_path_for_template(&path)
)
})
}
fn shlex_quote_path_for_template(path: &Path) -> String {
let path = path.to_string_lossy();
if path.chars().any(char::is_whitespace) || path.contains('\'') || path.contains('"') {
let escaped = path.replace('\'', r"'\''");
format!("'{escaped}'")
} else {
path.into_owned()
}
}
fn skipped_doctor_tool(name: &str, required: bool, message: String) -> DoctorTool {
DoctorTool {
name: name.to_string(),
required,
status: DoctorStatus::Skipped,
command: Vec::new(),
path: None,
message,
}
}
pub(crate) fn validate_dicom_path_with_runner(
path: impl AsRef<Path>,
options: &ValidationOptions,
runner: &impl ValidationCommandRunner,
) -> Result<ValidationReport, Error> {
let input = path.as_ref().to_path_buf();
let files = discover_dicom_files(&input, options)?;
let mut checks = Vec::new();
for file in &files {
checks.push(run_named_command_check(
runner,
CommandCheckRequest {
check_name: DCIODVFY_TOOL.name,
command_name: DCIODVFY_TOOL.name,
args: vec![OsString::from("-new"), file.as_os_str().to_os_string()],
path: Some(file),
required: options.strict,
error_line_is_failure: true,
timeout: options.command_timeout(),
max_output_bytes: options.max_child_output_bytes,
},
));
}
checks.extend(run_set_level_command_checks(
runner,
&files,
SetLevelCommandCheckRequest {
check_name: DCENTVFY_TOOL.name,
command_name: DCENTVFY_TOOL.name,
required: options.strict,
error_line_is_failure: true,
timeout: options.command_timeout(),
max_output_bytes: options.max_child_output_bytes,
chunk_size: VALIDATOR_SET_FILE_CHUNK_SIZE,
},
));
checks.extend(run_set_level_command_checks(
runner,
&files,
SetLevelCommandCheckRequest {
check_name: VALIDATE_IODS_TOOL.name,
command_name: VALIDATE_IODS_TOOL.name,
required: options.strict,
error_line_is_failure: false,
timeout: options.command_timeout(),
max_output_bytes: options.max_child_output_bytes,
chunk_size: VALIDATOR_SET_FILE_CHUNK_SIZE,
},
));
if let Some(iod) = &options.dcmvalidate_iod {
for file in &files {
checks.push(run_named_command_check(
runner,
CommandCheckRequest {
check_name: DCMVALIDATE_TOOL.name,
command_name: DCMVALIDATE_TOOL.name,
args: vec![
OsString::from("--iod"),
iod.as_os_str().to_os_string(),
file.as_os_str().to_os_string(),
],
path: Some(file),
required: true,
error_line_is_failure: false,
timeout: options.command_timeout(),
max_output_bytes: options.max_child_output_bytes,
},
));
}
}
if options.max_pixel_frames > 0 {
let temp_dir = ValidationTempDir::create()?;
for (file_idx, file) in files.iter().enumerate() {
checks.extend(run_pixel_decode_checks(
file_idx,
file,
options,
runner,
temp_dir.path(),
));
}
}
Ok(ValidationReport {
input,
files,
checks,
})
}
fn discover_dicom_files(input: &Path, options: &ValidationOptions) -> Result<Vec<PathBuf>, Error> {
let metadata = std::fs::symlink_metadata(input).map_err(|source| Error::Io {
path: input.to_path_buf(),
source,
})?;
let mut files = Vec::new();
if metadata.file_type().is_symlink() {
return Err(Error::Validation {
reason: format!("refusing to validate symlink path {}", input.display()),
});
} else if metadata.is_file() {
files.push(input.to_path_buf());
} else if metadata.is_dir() {
collect_dicom_files(input, options, &mut files)?;
files.sort();
} else {
return Err(Error::Validation {
reason: format!("{} is not a regular file or directory", input.display()),
});
}
if files.is_empty() {
return Err(Error::Validation {
reason: format!("no .dcm files found under {}", input.display()),
});
}
Ok(files)
}
fn collect_dicom_files(
root: &Path,
options: &ValidationOptions,
files: &mut Vec<PathBuf>,
) -> Result<(), Error> {
let mut pending = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = pending.pop() {
if depth > options.max_depth {
return Err(Error::Validation {
reason: format!(
"DICOM validation directory depth exceeds max_depth={} at {}",
options.max_depth,
dir.display()
),
});
}
let entries = std::fs::read_dir(&dir).map_err(|source| Error::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| Error::Io {
path: dir.clone(),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| Error::Io {
path: path.clone(),
source,
})?;
if file_type.is_symlink() {
return Err(Error::Validation {
reason: format!("refusing to traverse symlink {}", path.display()),
});
} else if file_type.is_dir() {
pending.push((path, depth + 1));
} else if file_type.is_file() && has_dcm_extension(&path) {
files.push(path);
if files.len() > options.max_files {
return Err(Error::Validation {
reason: format!(
"DICOM validation found more than max_files={} files",
options.max_files
),
});
}
}
}
}
Ok(())
}
fn has_dcm_extension(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("dcm"))
}
struct CommandCheckRequest<'a> {
check_name: &'a str,
command_name: &'a str,
args: Vec<OsString>,
path: Option<&'a PathBuf>,
required: bool,
error_line_is_failure: bool,
timeout: Duration,
max_output_bytes: usize,
}
struct SetLevelCommandCheckRequest<'a> {
check_name: &'a str,
command_name: &'a str,
required: bool,
error_line_is_failure: bool,
timeout: Duration,
max_output_bytes: usize,
chunk_size: usize,
}
fn run_set_level_command_checks(
runner: &impl ValidationCommandRunner,
files: &[PathBuf],
request: SetLevelCommandCheckRequest<'_>,
) -> Vec<ValidationCheck> {
let chunk_size = request.chunk_size.max(1);
files
.chunks(chunk_size)
.map(|chunk| {
run_named_command_check(
runner,
CommandCheckRequest {
check_name: request.check_name,
command_name: request.command_name,
args: chunk
.iter()
.map(|file| file.as_os_str().to_os_string())
.collect(),
path: None,
required: request.required,
error_line_is_failure: request.error_line_is_failure,
timeout: request.timeout,
max_output_bytes: request.max_output_bytes,
},
)
})
.collect()
}
fn run_named_command_check(
runner: &impl ValidationCommandRunner,
request: CommandCheckRequest<'_>,
) -> ValidationCheck {
let CommandCheckRequest {
check_name,
command_name,
args,
path,
required,
error_line_is_failure,
timeout,
max_output_bytes,
} = request;
let command = std::iter::once(command_name.to_string())
.chain(args.iter().map(|arg| arg.to_string_lossy().into_owned()))
.collect::<Vec<_>>();
let Some(program) = runner.find_command(command_name) else {
let status = if required {
ValidationStatus::Failed
} else {
ValidationStatus::Skipped
};
return ValidationCheck {
name: check_name.to_string(),
path: path.cloned(),
status,
command,
message: format!("{command_name} not found"),
stdout: String::new(),
stderr: String::new(),
};
};
match runner.run(&program, &args, timeout, max_output_bytes) {
Ok(outcome) => {
if outcome.stdout_truncated || outcome.stderr_truncated {
return ValidationCheck {
name: check_name.to_string(),
path: path.cloned(),
status: ValidationStatus::Failed,
command,
message: format!(
"{command_name} output exceeded {} byte capture limit",
max_output_bytes
),
stdout: outcome.stdout,
stderr: outcome.stderr,
};
}
if outcome.timed_out {
return ValidationCheck {
name: check_name.to_string(),
path: path.cloned(),
status: ValidationStatus::Failed,
command,
message: format!("{command_name} timed out after {}", format_timeout(timeout)),
stdout: outcome.stdout,
stderr: outcome.stderr,
};
}
let output_has_error = error_line_is_failure
&& outcome
.stdout
.lines()
.chain(outcome.stderr.lines())
.any(|line| line.trim_start().starts_with("Error"));
let status = if outcome.success && !output_has_error {
ValidationStatus::Passed
} else {
ValidationStatus::Failed
};
ValidationCheck {
name: check_name.to_string(),
path: path.cloned(),
status,
command,
message: if status == ValidationStatus::Passed {
format!("{command_name} passed")
} else {
format!("{command_name} failed")
},
stdout: outcome.stdout,
stderr: outcome.stderr,
}
}
Err(source) => ValidationCheck {
name: check_name.to_string(),
path: path.cloned(),
status: ValidationStatus::Failed,
command,
message: format!("failed to start {command_name}: {source}"),
stdout: String::new(),
stderr: String::new(),
},
}
}
fn format_timeout(timeout: Duration) -> String {
if timeout.as_millis() > 0 && timeout.as_millis() < 1000 {
format!("{}ms", timeout.as_millis())
} else if timeout.subsec_millis() == 0 {
format!("{}s", timeout.as_secs())
} else {
format!("{}ms", timeout.as_millis())
}
}
fn run_pixel_decode_checks(
file_idx: usize,
file: &PathBuf,
options: &ValidationOptions,
runner: &impl ValidationCommandRunner,
temp_dir: &Path,
) -> Vec<ValidationCheck> {
let object = match dicom_object::open_file(file) {
Ok(object) => object,
Err(err) => {
return vec![failed_check(
"pixel-decode",
Some(file),
format!("failed to read DICOM file for pixel decode: {err}"),
)];
}
};
let transfer_syntax = object.meta().transfer_syntax.trim_end_matches('\0');
let Some(decoder) = pixel_decoder_for_transfer_syntax(transfer_syntax, options, runner) else {
return vec![skipped_check(
"pixel-decode",
Some(file),
format!("pixel decode not needed for transfer syntax {transfer_syntax}"),
)];
};
if let PixelDecoder::Htj2kUnconfigured = decoder {
let status = if options.strict {
ValidationStatus::Failed
} else {
ValidationStatus::Skipped
};
return vec![ValidationCheck {
name: "pixel-htj2k".to_string(),
path: Some(file.clone()),
status,
command: Vec::new(),
message: "HTJ2K decoder command is not configured".to_string(),
stdout: String::new(),
stderr: String::new(),
}];
}
let expected = match decoded_frame_expectation(&object) {
Ok(expected) => expected,
Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
};
let frame_count = match object.element(tags::NUMBER_OF_FRAMES) {
Ok(element) => match element.to_int::<usize>() {
Ok(frame_count) if frame_count > 0 => frame_count,
Ok(_) => {
return vec![failed_check(
"pixel-decode",
Some(file),
"DICOM Number of Frames must be greater than zero".to_string(),
)];
}
Err(err) => {
return vec![failed_check(
"pixel-decode",
Some(file),
format!("failed to read DICOM Number of Frames: {err}"),
)];
}
},
Err(err) => {
return vec![failed_check(
"pixel-decode",
Some(file),
format!("DICOM Number of Frames is missing: {err}"),
)];
}
};
let pixel_data = match object.element(tags::PIXEL_DATA) {
Ok(pixel_data) => pixel_data,
Err(err) => {
return vec![failed_check(
"pixel-decode",
Some(file),
format!("failed to read Pixel Data: {err}"),
)];
}
};
let Value::PixelSequence(pixel_sequence) = pixel_data.value() else {
return vec![skipped_check(
"pixel-decode",
Some(file),
"Pixel Data is not encapsulated".to_string(),
)];
};
if pixel_sequence.fragments().is_empty() {
return vec![skipped_check(
"pixel-decode",
Some(file),
"Pixel Data has no fragments".to_string(),
)];
}
let extended_offsets = match optional_u64_values(&object, tags::EXTENDED_OFFSET_TABLE) {
Ok(values) => values,
Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
};
let extended_lengths = match optional_u64_values(&object, tags::EXTENDED_OFFSET_TABLE_LENGTHS) {
Ok(values) => values,
Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
};
let frames = match assemble_encapsulated_frames(
pixel_sequence,
frame_count,
extended_offsets.as_deref(),
extended_lengths.as_deref(),
options.max_pixel_frames,
options.max_pixel_frame_bytes,
) {
Ok(frames) => frames,
Err(message) => return vec![failed_check("pixel-decode", Some(file), message)],
};
let mut checks = Vec::new();
for (frame_idx, frame) in frames.iter().enumerate() {
checks.push(run_pixel_decoder_for_fragment(
&decoder,
PixelFragmentDecode {
file_idx,
frame_idx,
fragment: frame,
file,
runner,
temp_dir,
strict: options.strict,
timeout: options.command_timeout(),
max_output_bytes: options.max_child_output_bytes,
max_decoded_bytes: options.max_pixel_frame_bytes,
expected,
},
));
}
checks
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DecodedFrameExpectation {
columns: u32,
rows: u32,
samples_per_pixel: Option<u16>,
bits_allocated: Option<u16>,
}
fn decoded_frame_expectation(
object: &dicom_object::DefaultDicomObject,
) -> Result<DecodedFrameExpectation, String> {
let columns = object
.element(tags::COLUMNS)
.map_err(|err| format!("DICOM Columns is missing: {err}"))?
.to_int::<u32>()
.map_err(|err| format!("failed to read DICOM Columns: {err}"))?;
let rows = object
.element(tags::ROWS)
.map_err(|err| format!("DICOM Rows is missing: {err}"))?
.to_int::<u32>()
.map_err(|err| format!("failed to read DICOM Rows: {err}"))?;
if columns == 0 || rows == 0 {
return Err("DICOM Rows and Columns must be greater than zero".to_string());
}
let samples_per_pixel = object
.element(tags::SAMPLES_PER_PIXEL)
.ok()
.map(|element| {
element
.to_int::<u16>()
.map_err(|err| format!("failed to read DICOM Samples per Pixel: {err}"))
})
.transpose()?;
let bits_allocated = object
.element(tags::BITS_ALLOCATED)
.ok()
.map(|element| {
element
.to_int::<u16>()
.map_err(|err| format!("failed to read DICOM Bits Allocated: {err}"))
})
.transpose()?;
Ok(DecodedFrameExpectation {
columns,
rows,
samples_per_pixel,
bits_allocated,
})
}
fn optional_u64_values(
object: &dicom_object::DefaultDicomObject,
tag: dicom_core::Tag,
) -> Result<Option<Vec<u64>>, String> {
let Ok(element) = object.element(tag) else {
return Ok(None);
};
element
.to_multi_int::<u64>()
.map(Some)
.map_err(|err| format!("failed to read DICOM element {tag}: {err}"))
}
fn assemble_encapsulated_frames(
sequence: &PixelFragmentSequence<Vec<u8>>,
frame_count: usize,
extended_offsets: Option<&[u64]>,
extended_lengths: Option<&[u64]>,
max_frames: usize,
max_frame_bytes: usize,
) -> Result<Vec<Vec<u8>>, String> {
let fragments = sequence.fragments();
if fragments.is_empty() {
return Err("Pixel Data has no fragments".to_string());
}
let basic_offsets = sequence.offset_table();
let offsets = match extended_offsets {
Some(offsets) if !offsets.is_empty() => offsets.to_vec(),
_ if !basic_offsets.is_empty() => basic_offsets
.iter()
.map(|&value| u64::from(value))
.collect(),
_ => Vec::new(),
};
let lengths = extended_lengths.filter(|lengths| !lengths.is_empty());
if lengths.is_some() && extended_offsets.is_none_or(<[u64]>::is_empty) {
return Err("Extended Offset Table Lengths requires an Extended Offset Table".to_string());
}
if let Some(lengths) = lengths {
if lengths.len() != frame_count {
return Err(format!(
"Extended Offset Table Lengths has {} entries for {frame_count} frames",
lengths.len()
));
}
}
let spans = if offsets.is_empty() {
if frame_count == 1 {
vec![(0, fragments.len())]
} else if frame_count == fragments.len() {
(0..fragments.len())
.map(|index| (index, index + 1))
.collect()
} else {
return Err(format!(
"cannot map {} Pixel Data fragments to {frame_count} frames without an offset table",
fragments.len()
));
}
} else {
if offsets.len() != frame_count {
return Err(format!(
"Pixel Data offset table has {} entries for {frame_count} frames",
offsets.len()
));
}
let mut fragment_offsets = Vec::with_capacity(fragments.len());
let mut next_offset = 0u64;
for fragment in fragments {
fragment_offsets.push(next_offset);
let fragment_len = u64::try_from(fragment.len())
.map_err(|_| "Pixel Data fragment length exceeds u64".to_string())?;
next_offset = next_offset
.checked_add(8)
.and_then(|offset| offset.checked_add(fragment_len))
.ok_or_else(|| "Pixel Data fragment offsets overflow u64".to_string())?;
}
let mut starts = Vec::with_capacity(offsets.len());
for offset in offsets {
let index = fragment_offsets.binary_search(&offset).map_err(|_| {
format!("Pixel Data frame offset {offset} does not identify a fragment boundary")
})?;
starts.push(index);
}
if starts.first() != Some(&0) || starts.windows(2).any(|pair| pair[0] >= pair[1]) {
return Err(
"Pixel Data frame offsets are not strictly increasing from zero".to_string(),
);
}
starts
.iter()
.enumerate()
.map(|(index, &start)| {
let end = starts.get(index + 1).copied().unwrap_or(fragments.len());
(start, end)
})
.collect()
};
let mut frames = Vec::with_capacity(max_frames.min(frame_count));
for (frame_index, &(start, end)) in spans.iter().take(max_frames).enumerate() {
let assembled_len = fragments[start..end]
.iter()
.try_fold(0usize, |total, fragment| {
total
.checked_add(fragment.len())
.ok_or_else(|| "assembled Pixel Data frame length overflows usize".to_string())
})?;
let output_len = match lengths {
Some(lengths) => usize::try_from(lengths[frame_index]).map_err(|_| {
format!("Pixel Data frame {frame_index} length exceeds platform limits")
})?,
None => assembled_len,
};
if output_len > assembled_len {
return Err(format!(
"Pixel Data frame {frame_index} declares {output_len} bytes but only {assembled_len} are available"
));
}
if output_len > max_frame_bytes {
return Err(format!(
"Pixel Data frame {frame_index} exceeds {max_frame_bytes} byte validation limit"
));
}
let mut frame = Vec::with_capacity(output_len);
for fragment in &fragments[start..end] {
let remaining = output_len.saturating_sub(frame.len());
if remaining == 0 {
break;
}
frame.extend_from_slice(&fragment[..fragment.len().min(remaining)]);
}
frames.push(frame);
}
Ok(frames)
}
enum PixelDecoder {
Djpeg,
OpenJpeg,
Htj2kUnconfigured,
Htj2k { template: String },
}
fn pixel_decoder_for_transfer_syntax(
transfer_syntax_uid: &str,
options: &ValidationOptions,
runner: &impl ValidationCommandRunner,
) -> Option<PixelDecoder> {
match transfer_syntax_uid {
uid if uid == TransferSyntax::JpegBaseline8Bit.uid() => Some(PixelDecoder::Djpeg),
uid if uid == TransferSyntax::Jpeg2000.uid()
|| uid == TransferSyntax::Jpeg2000Lossless.uid() =>
{
Some(PixelDecoder::OpenJpeg)
}
uid if uid == TransferSyntax::Htj2k.uid()
|| uid == TransferSyntax::Htj2kLossless.uid()
|| uid == TransferSyntax::Htj2kLosslessRpcl.uid() =>
{
Some(
options
.htj2k_decoder
.clone()
.or_else(|| auto_htj2k_decoder_template(runner))
.as_ref()
.map(|template| PixelDecoder::Htj2k {
template: template.clone(),
})
.unwrap_or(PixelDecoder::Htj2kUnconfigured),
)
}
_ => None,
}
}
struct PixelFragmentDecode<'a, R: ValidationCommandRunner> {
file_idx: usize,
frame_idx: usize,
fragment: &'a [u8],
file: &'a PathBuf,
runner: &'a R,
temp_dir: &'a Path,
strict: bool,
timeout: Duration,
max_output_bytes: usize,
max_decoded_bytes: usize,
expected: DecodedFrameExpectation,
}
fn run_pixel_decoder_for_fragment<R: ValidationCommandRunner>(
decoder: &PixelDecoder,
request: PixelFragmentDecode<'_, R>,
) -> ValidationCheck {
let input = request.temp_dir.join(format!(
"file-{:04}-frame-{:06}.codestream",
request.file_idx, request.frame_idx
));
let output = request.temp_dir.join(format!(
"file-{:04}-frame-{:06}.ppm",
request.file_idx, request.frame_idx
));
if let Err(err) = write_private_validation_file(&input, request.fragment) {
return failed_check(
"pixel-decode",
Some(request.file),
format!("failed to write temporary codestream: {err}"),
);
}
let check = match decoder {
PixelDecoder::Djpeg => run_named_command_check(
request.runner,
CommandCheckRequest {
check_name: "pixel-djpeg",
command_name: "djpeg",
args: vec![
OsString::from("-outfile"),
output.as_os_str().to_os_string(),
input.as_os_str().to_os_string(),
],
path: Some(request.file),
required: request.strict,
error_line_is_failure: false,
timeout: request.timeout,
max_output_bytes: request.max_output_bytes,
},
),
PixelDecoder::OpenJpeg => run_named_command_check(
request.runner,
CommandCheckRequest {
check_name: "pixel-opj-decompress",
command_name: "opj_decompress",
args: vec![
OsString::from("-i"),
input.as_os_str().to_os_string(),
OsString::from("-o"),
output.as_os_str().to_os_string(),
],
path: Some(request.file),
required: request.strict,
error_line_is_failure: false,
timeout: request.timeout,
max_output_bytes: request.max_output_bytes,
},
),
PixelDecoder::Htj2k { template } => {
let (command, args) = match htj2k_decoder_command(template, &input, &output) {
Ok(command) => command,
Err(message) => {
return failed_check("pixel-htj2k", Some(request.file), message);
}
};
run_named_command_check(
request.runner,
CommandCheckRequest {
check_name: "pixel-htj2k",
command_name: &command,
args,
path: Some(request.file),
required: request.strict,
error_line_is_failure: false,
timeout: request.timeout,
max_output_bytes: request.max_output_bytes,
},
)
}
PixelDecoder::Htj2kUnconfigured => skipped_check(
"pixel-htj2k",
Some(request.file),
"HTJ2K decoder command is not configured".to_string(),
),
};
validate_decoded_output(check, &output, request.expected, request.max_decoded_bytes)
}
fn validate_decoded_output(
mut check: ValidationCheck,
output: &Path,
expected: DecodedFrameExpectation,
max_decoded_bytes: usize,
) -> ValidationCheck {
if check.status != ValidationStatus::Passed {
return check;
}
if let Err(message) = inspect_pnm_output(output, expected, max_decoded_bytes) {
check.status = ValidationStatus::Failed;
check.message = message;
}
check
}
fn inspect_pnm_output(
output: &Path,
expected: DecodedFrameExpectation,
max_decoded_bytes: usize,
) -> Result<(), String> {
let mut file = fs::File::open(output).map_err(|err| {
format!(
"decoder did not create readable output {}: {err}",
output.display()
)
})?;
let file_len = file
.metadata()
.map_err(|err| format!("inspect decoder output {}: {err}", output.display()))?
.len();
let max_decoded_bytes = u64::try_from(max_decoded_bytes).unwrap_or(u64::MAX);
if file_len > max_decoded_bytes {
return Err(format!(
"decoder output {} exceeds {max_decoded_bytes} byte validation limit",
output.display()
));
}
let magic = read_pnm_token(&mut file)?;
let components = match magic.as_str() {
"P5" => 1u64,
"P6" => 3u64,
_ => {
return Err(format!(
"decoder output uses unsupported PNM magic {magic:?}"
))
}
};
let columns = parse_pnm_u32(&mut file, "width")?;
let rows = parse_pnm_u32(&mut file, "height")?;
let max_value = parse_pnm_u32(&mut file, "maximum sample value")?;
if columns != expected.columns || rows != expected.rows {
return Err(format!(
"decoder output dimensions {columns}x{rows} do not match DICOM {}x{}",
expected.columns, expected.rows
));
}
if let Some(samples_per_pixel) = expected.samples_per_pixel {
if u64::from(samples_per_pixel) != components {
return Err(format!(
"decoder output has {components} component(s), expected {samples_per_pixel}"
));
}
}
if !matches!(max_value, 255 | 65_535) {
return Err(format!(
"decoder output maximum sample value {max_value} is unsupported"
));
}
if let Some(bits_allocated) = expected.bits_allocated {
let expected_max = match bits_allocated {
8 => 255,
16 => 65_535,
other => {
return Err(format!(
"DICOM Bits Allocated {other} is unsupported for PNM validation"
));
}
};
if max_value != expected_max {
return Err(format!(
"decoder output maximum sample value {max_value} does not match {bits_allocated}-bit DICOM pixels"
));
}
}
let bytes_per_sample = if max_value > 255 { 2u64 } else { 1u64 };
let payload_len = u64::from(columns)
.checked_mul(u64::from(rows))
.and_then(|value| value.checked_mul(components))
.and_then(|value| value.checked_mul(bytes_per_sample))
.ok_or_else(|| "decoder output dimensions overflow payload length".to_string())?;
let payload_start = file
.stream_position()
.map_err(|err| format!("inspect decoder output payload: {err}"))?;
let expected_file_len = payload_start
.checked_add(payload_len)
.ok_or_else(|| "decoder output length overflows u64".to_string())?;
if file_len != expected_file_len {
return Err(format!(
"decoder output payload has {} bytes, expected {payload_len}",
file_len.saturating_sub(payload_start)
));
}
Ok(())
}
fn parse_pnm_u32(file: &mut fs::File, field: &str) -> Result<u32, String> {
let token = read_pnm_token(file)?;
token
.parse::<u32>()
.map_err(|err| format!("decoder output has invalid PNM {field} {token:?}: {err}"))
}
fn read_pnm_token(file: &mut fs::File) -> Result<String, String> {
let mut token = Vec::new();
let mut in_comment = false;
loop {
let mut byte = [0u8; 1];
if file
.read(&mut byte)
.map_err(|err| format!("read decoder PNM header: {err}"))?
== 0
{
if token.is_empty() {
return Err("decoder output ended inside the PNM header".to_string());
}
break;
}
let byte = byte[0];
if in_comment {
if byte == b'\n' {
in_comment = false;
}
continue;
}
if token.is_empty() && byte == b'#' {
in_comment = true;
continue;
}
if byte.is_ascii_whitespace() {
if token.is_empty() {
continue;
}
break;
}
token.push(byte);
if token.len() > 64 {
return Err("decoder output PNM header token exceeds 64 bytes".to_string());
}
}
String::from_utf8(token).map_err(|err| format!("decoder output PNM header is not ASCII: {err}"))
}
pub(crate) fn htj2k_decoder_command(
template: &str,
input: &Path,
output: &Path,
) -> Result<(String, Vec<OsString>), String> {
let mut parts = shlex::split(template)
.ok_or_else(|| "HTJ2K decoder command has invalid quoting".to_string())?;
if parts.is_empty() {
return Err("HTJ2K decoder command is empty".to_string());
}
let command = parts.remove(0);
if command.trim().is_empty() {
return Err("HTJ2K decoder command is empty".to_string());
}
if !Path::new(&command).is_absolute() {
return Err(
"HTJ2K decoder command must start with an absolute executable path".to_string(),
);
}
let mut saw_placeholder = false;
let mut args = parts
.into_iter()
.map(|part| {
let replaced = part
.replace("{input}", &input.to_string_lossy())
.replace("{output}", &output.to_string_lossy());
if replaced != part {
saw_placeholder = true;
}
OsString::from(replaced)
})
.collect::<Vec<_>>();
if !saw_placeholder {
args.push(input.as_os_str().to_os_string());
}
Ok((command, args))
}
#[cfg(any(test, feature = "bench-internals"))]
pub(crate) fn fragment_payload_without_padding(fragment: &[u8]) -> &[u8] {
fragment
}
fn failed_check(name: &str, path: Option<&PathBuf>, message: String) -> ValidationCheck {
ValidationCheck {
name: name.to_string(),
path: path.cloned(),
status: ValidationStatus::Failed,
command: Vec::new(),
message,
stdout: String::new(),
stderr: String::new(),
}
}
fn skipped_check(name: &str, path: Option<&PathBuf>, message: String) -> ValidationCheck {
ValidationCheck {
name: name.to_string(),
path: path.cloned(),
status: ValidationStatus::Skipped,
command: Vec::new(),
message,
stdout: String::new(),
stderr: String::new(),
}
}
fn write_private_validation_file(path: &Path, bytes: &[u8]) -> Result<(), Error> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
file.write_all(bytes).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
file.sync_all().map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})
}
struct ValidationTempDir {
inner: tempfile::TempDir,
}
impl ValidationTempDir {
fn create() -> Result<Self, Error> {
let inner = tempfile::Builder::new()
.prefix("wsi-dicom-validation-")
.tempdir()
.map_err(|source| Error::Io {
path: std::env::temp_dir(),
source,
})?;
set_private_validation_dir_permissions(inner.path())?;
Ok(Self { inner })
}
fn path(&self) -> &Path {
self.inner.path()
}
}
#[cfg(unix)]
fn set_private_validation_dir_permissions(path: &Path) -> Result<(), Error> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn set_private_validation_dir_permissions(_path: &Path) -> Result<(), Error> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
doctor_dicom_environment_with_runner, validate_dicom_path_with_runner, CommandOutcome,
DoctorOptions, DoctorStatus, SystemCommandRunner, ValidationCommandRunner,
ValidationOptions, ValidationStatus,
};
use dicom_core::{DataElement, PrimitiveValue, VR};
use dicom_dictionary_std::tags;
use dicom_object::{FileMetaTableBuilder, InMemDicomObject};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(not(windows))]
const ABSOLUTE_HTJ2K_DECODER: &str = "/usr/local/bin/ojph_expand";
#[cfg(windows)]
const ABSOLUTE_HTJ2K_DECODER: &str = "C:/Tools/ojph_expand.exe";
#[cfg(not(windows))]
const ABSOLUTE_GROK_DECODER: &str = "/usr/local/bin/grk_decompress";
#[cfg(windows)]
const ABSOLUTE_GROK_DECODER: &str = "C:/Tools/grk_decompress.exe";
#[derive(Default)]
struct FakeRunner {
commands: BTreeSet<String>,
command_paths: BTreeMap<String, PathBuf>,
outcomes: BTreeMap<String, CommandOutcome>,
}
impl FakeRunner {
fn with_command(mut self, name: &str) -> Self {
self.commands.insert(name.to_string());
self
}
fn with_command_path(mut self, name: &str, path: &str) -> Self {
self.command_paths
.insert(name.to_string(), PathBuf::from(path));
self.commands.insert(path.to_string());
self
}
fn with_outcome(mut self, command: &str, outcome: CommandOutcome) -> Self {
self.outcomes.insert(command.to_string(), outcome);
self
}
}
impl ValidationCommandRunner for FakeRunner {
fn find_command(&self, name: &str) -> Option<PathBuf> {
if let Some(path) = self.command_paths.get(name) {
return Some(path.clone());
}
self.commands.contains(name).then(|| PathBuf::from(name))
}
fn run(
&self,
program: &Path,
args: &[OsString],
_timeout: Duration,
_max_output_bytes: usize,
) -> Result<CommandOutcome, std::io::Error> {
let mut key = program.display().to_string();
for arg in args {
key.push(' ');
key.push_str(&arg.to_string_lossy());
}
let outcome = self.outcomes.get(&key).cloned().unwrap_or(CommandOutcome {
success: true,
timed_out: false,
stdout: String::new(),
stderr: String::new(),
stdout_truncated: false,
stderr_truncated: false,
});
if outcome.success {
for pair in args.windows(2) {
if matches!(pair[0].to_str(), Some("-o" | "-outfile")) {
std::fs::write(PathBuf::from(&pair[1]), b"P6\n1 1\n255\n\x00\x00\x00")?;
}
}
}
Ok(outcome)
}
}
#[cfg(unix)]
#[test]
fn system_runner_drains_stdout_while_waiting_for_child_exit() {
let runner = SystemCommandRunner;
let outcome = runner
.run(
Path::new("/bin/sh"),
&[
OsString::from("-c"),
OsString::from("yes validation-output | head -c 200000"),
],
Duration::from_secs(5),
4 * 1024 * 1024,
)
.unwrap();
assert!(outcome.success);
assert_eq!(outcome.stdout.len(), 200_000);
assert!(!outcome.timed_out);
}
#[cfg(unix)]
#[test]
fn system_runner_timeout_terminates_descendants_and_returns_promptly() {
let runner = SystemCommandRunner;
let started = std::time::Instant::now();
let outcome = runner
.run(
Path::new("/bin/sh"),
&[OsString::from("-c"), OsString::from("sleep 30 & wait")],
Duration::from_millis(100),
1024,
)
.unwrap();
assert!(outcome.timed_out);
assert!(started.elapsed() < Duration::from_secs(3));
}
#[test]
fn validation_discovers_dicom_files_recursively() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("nested");
std::fs::create_dir(&nested).expect("create nested");
let first = tmp.path().join("one.dcm");
let second = nested.join("two.DCM");
std::fs::write(&first, b"not parsed without pixel checks").expect("write first");
std::fs::write(&second, b"not parsed without pixel checks").expect("write second");
std::fs::write(tmp.path().join("notes.txt"), b"ignore").expect("write ignored");
let report = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert_eq!(report.files, vec![second, first]);
}
#[test]
fn validation_enforces_file_and_depth_limits() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("nested");
std::fs::create_dir(&nested).expect("create nested");
std::fs::write(tmp.path().join("one.dcm"), b"one").expect("write one");
std::fs::write(nested.join("two.dcm"), b"two").expect("write two");
let err = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
max_files: 1,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.unwrap_err();
assert!(err.to_string().contains("max_files"));
let err = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
max_depth: 0,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.unwrap_err();
assert!(err.to_string().contains("max_depth"));
}
#[cfg(unix)]
#[test]
fn validation_refuses_symlink_traversal() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("target");
std::fs::create_dir(&target).expect("create target");
std::fs::write(target.join("one.dcm"), b"one").expect("write one");
std::os::unix::fs::symlink(&target, tmp.path().join("link")).expect("symlink");
let err = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.unwrap_err();
assert!(err.to_string().contains("symlink"));
}
#[test]
fn missing_tools_are_skipped_by_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("one.dcm");
std::fs::write(&file, b"not parsed without pixel checks").expect("write file");
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report
.checks
.iter()
.any(|check| check.name == "dciodvfy" && check.status == ValidationStatus::Skipped));
assert!(!report.has_failures());
}
#[test]
fn strict_mode_fails_missing_required_tools() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("one.dcm");
std::fs::write(&file, b"not parsed without pixel checks").expect("write file");
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
strict: true,
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report
.checks
.iter()
.any(|check| check.name == "dciodvfy" && check.status == ValidationStatus::Failed));
assert!(report.has_failures());
}
#[test]
fn dcentvfy_runs_once_for_the_output_set() {
let tmp = tempfile::tempdir().expect("tempdir");
let first = tmp.path().join("one.dcm");
let second = tmp.path().join("two.dcm");
std::fs::write(&first, b"not parsed without pixel checks").expect("write first");
std::fs::write(&second, b"not parsed without pixel checks").expect("write second");
let report = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default().with_command("dcentvfy"),
)
.expect("validation report");
let set_checks = report
.checks
.iter()
.filter(|check| check.name == "dcentvfy")
.count();
assert_eq!(set_checks, 1);
}
#[test]
fn set_level_validators_are_chunked_and_preserve_failures() {
let tmp = tempfile::tempdir().expect("tempdir");
for idx in 0..=super::VALIDATOR_SET_FILE_CHUNK_SIZE {
std::fs::write(
tmp.path().join(format!("file-{idx:04}.dcm")),
b"not parsed without pixel checks",
)
.expect("write DICOM placeholder");
}
let failing_file = tmp.path().join(format!(
"file-{:04}.dcm",
super::VALIDATOR_SET_FILE_CHUNK_SIZE
));
let failing_key = format!("dcentvfy {}", failing_file.display());
let runner = FakeRunner::default().with_command("dcentvfy").with_outcome(
&failing_key,
CommandOutcome {
success: false,
timed_out: false,
stdout: String::new(),
stderr: "set check failed".to_string(),
stdout_truncated: false,
stderr_truncated: false,
},
);
let report = validate_dicom_path_with_runner(
tmp.path(),
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&runner,
)
.expect("validation report");
let dcentvfy_checks = report
.checks
.iter()
.filter(|check| check.name == "dcentvfy")
.collect::<Vec<_>>();
assert_eq!(dcentvfy_checks.len(), 2);
assert!(dcentvfy_checks
.iter()
.all(|check| check.command.len() <= super::VALIDATOR_SET_FILE_CHUNK_SIZE + 1));
assert!(dcentvfy_checks
.iter()
.any(|check| check.status == ValidationStatus::Failed));
assert!(report.has_failures());
}
#[test]
fn jpeg_baseline_pixel_decode_uses_djpeg() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("jpeg.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions::default(),
&FakeRunner::default().with_command("djpeg"),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-djpeg" && check.status == ValidationStatus::Passed
}));
}
#[test]
fn jpeg2000_pixel_decode_uses_openjpeg() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("j2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.90", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions::default(),
&FakeRunner::default().with_command("opj_decompress"),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-opj-decompress" && check.status == ValidationStatus::Passed
}));
}
#[test]
fn htj2k_pixel_decode_uses_auto_grok_decoder_when_available() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("htj2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions::default(),
&FakeRunner::default()
.with_command_path(super::AUTO_HTJ2K_DECODER_COMMAND, ABSOLUTE_GROK_DECODER),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-htj2k"
&& check.status == ValidationStatus::Passed
&& check
.command
.first()
.is_some_and(|command| command == ABSOLUTE_GROK_DECODER)
}));
}
#[test]
fn htj2k_pixel_decode_skips_without_configured_or_auto_decoder() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("htj2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions::default(),
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-htj2k" && check.status == ValidationStatus::Skipped
}));
}
#[test]
fn strict_htj2k_pixel_decode_fails_without_configured_or_auto_decoder() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("htj2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
strict: true,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-htj2k" && check.status == ValidationStatus::Failed
}));
assert!(report.has_failures());
}
#[test]
fn strict_mode_fails_missing_pixel_decoder_for_encountered_transfer_syntax() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("jpeg.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
strict: true,
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-djpeg" && check.status == ValidationStatus::Failed
}));
}
#[test]
fn zero_pixel_frame_limit_disables_pixel_decode_checks() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("jpeg.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.50", &[0xFF, 0xD8, 0xFF, 0xD9]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
max_pixel_frames: 0,
..ValidationOptions::default()
},
&FakeRunner::default().with_command("djpeg"),
)
.expect("validation report");
assert!(!report
.checks
.iter()
.any(|check| check.name.starts_with("pixel-")));
}
#[test]
fn uncompressed_transfer_syntax_skips_pixel_decode() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("explicit.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.1", &[1, 2, 3, 4]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions::default(),
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-decode" && check.status == ValidationStatus::Skipped
}));
}
#[test]
fn htj2k_decoder_template_preserves_quoted_arguments() {
let input = Path::new("/tmp/input codestream.j2k");
let output = Path::new("/tmp/output pixels.ppm");
let template =
format!("{ABSOLUTE_HTJ2K_DECODER} --codec \"Open JPH\" -i {{input}} -o {{output}}");
let (command, args) =
super::htj2k_decoder_command(&template, input, output).expect("parse decoder command");
assert_eq!(command, ABSOLUTE_HTJ2K_DECODER);
assert_eq!(
args,
vec![
OsString::from("--codec"),
OsString::from("Open JPH"),
OsString::from("-i"),
input.as_os_str().to_os_string(),
OsString::from("-o"),
output.as_os_str().to_os_string(),
]
);
}
#[test]
fn htj2k_decoder_template_rejects_bare_command_name() {
let err = super::htj2k_decoder_command(
"ojph_expand -i {input} -o {output}",
Path::new("/tmp/input.jhc"),
Path::new("/tmp/output.ppm"),
)
.unwrap_err();
assert!(err.contains("absolute executable path"));
}
#[test]
fn empty_htj2k_decoder_template_is_reported_as_configuration_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("htj2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
htj2k_decoder: Some(" ".to_string()),
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-htj2k"
&& check.status == ValidationStatus::Failed
&& check.message.contains("HTJ2K decoder command is empty")
}));
}
#[test]
fn bare_htj2k_decoder_template_is_reported_as_configuration_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("htj2k.dcm");
write_encapsulated_dicom(&file, "1.2.840.10008.1.2.4.202", &[0xFF, 0x4F, 0xFF, 0x51]);
let report = validate_dicom_path_with_runner(
&file,
&ValidationOptions {
htj2k_decoder: Some("ojph_expand -i {input} -o {output}".to_string()),
..ValidationOptions::default()
},
&FakeRunner::default(),
)
.expect("validation report");
assert!(report.checks.iter().any(|check| {
check.name == "pixel-htj2k"
&& check.status == ValidationStatus::Failed
&& check.message.contains("absolute executable path")
}));
}
#[test]
fn fragment_payload_ending_in_zero_is_preserved_for_validation() {
assert_eq!(
super::fragment_payload_without_padding(&[0xFF, 0x4F, 0x00]),
&[0xFF, 0x4F, 0x00]
);
assert_eq!(
super::fragment_payload_without_padding(&[0xFF, 0x4F, 0x00, 0x00]),
&[0xFF, 0x4F, 0x00, 0x00]
);
}
#[test]
fn encapsulated_frame_assembly_uses_offsets_and_extended_lengths() {
let sequence = dicom_core::value::PixelFragmentSequence::new_fragments(vec![
vec![1, 2],
vec![3, 0],
vec![4, 5],
]);
let frames =
super::assemble_encapsulated_frames(&sequence, 2, Some(&[0, 20]), Some(&[3, 2]), 2, 64)
.unwrap();
assert_eq!(frames, vec![vec![1, 2, 3], vec![4, 5]]);
}
#[test]
fn encapsulated_frame_assembly_rejects_ambiguous_fragment_mapping() {
let sequence = dicom_core::value::PixelFragmentSequence::new_fragments(vec![
vec![1],
vec![2],
vec![3],
]);
let error = super::assemble_encapsulated_frames(&sequence, 2, None, None, 2, 64)
.expect_err("multiple frames without offsets must be unambiguous");
assert!(error.contains("without an offset table"));
}
#[test]
fn decoded_output_must_exist_and_match_dicom_geometry() {
let tmp = tempfile::tempdir().unwrap();
let output = tmp.path().join("frame.ppm");
let expected = super::DecodedFrameExpectation {
columns: 2,
rows: 1,
samples_per_pixel: Some(3),
bits_allocated: Some(8),
};
let missing = super::inspect_pnm_output(&output, expected, 1024)
.expect_err("missing output must fail");
assert!(missing.contains("did not create readable output"));
std::fs::write(&output, b"P6\n2 1\n255\n\x01\x02\x03\x04\x05\x06").unwrap();
super::inspect_pnm_output(&output, expected, 1024).unwrap();
std::fs::write(&output, b"P6\n1 1\n255\n\x01\x02\x03").unwrap();
let wrong_geometry = super::inspect_pnm_output(&output, expected, 1024)
.expect_err("wrong dimensions must fail");
assert!(wrong_geometry.contains("do not match DICOM"));
}
#[cfg(unix)]
#[test]
fn validation_temp_dir_and_codestream_files_are_private() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = super::ValidationTempDir::create().expect("validation temp dir");
let dir_mode = std::fs::metadata(temp_dir.path())
.expect("temp dir metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
let codestream = temp_dir.path().join("frame.codestream");
super::write_private_validation_file(&codestream, b"codestream").expect("write codestream");
let file_mode = std::fs::metadata(&codestream)
.expect("codestream metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(file_mode, 0o600);
}
#[test]
fn command_timeout_is_reported_as_failed_check() {
let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
"dciodvfy -new one.dcm",
CommandOutcome {
success: false,
timed_out: true,
stdout: String::new(),
stderr: String::new(),
stdout_truncated: false,
stderr_truncated: false,
},
);
let check = super::run_named_command_check(
&runner,
super::CommandCheckRequest {
check_name: "dciodvfy",
command_name: "dciodvfy",
args: vec![OsString::from("-new"), OsString::from("one.dcm")],
path: None,
required: true,
error_line_is_failure: true,
timeout: std::time::Duration::from_millis(25),
max_output_bytes: 4 * 1024 * 1024,
},
);
assert_eq!(check.status, ValidationStatus::Failed);
assert!(check.message.contains("timed out after 25ms"));
}
#[test]
fn command_output_limit_is_reported_as_failed_check() {
let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
"dciodvfy -new one.dcm",
CommandOutcome {
success: true,
timed_out: false,
stdout: "prefix".to_string(),
stderr: String::new(),
stdout_truncated: true,
stderr_truncated: false,
},
);
let check = super::run_named_command_check(
&runner,
super::CommandCheckRequest {
check_name: "dciodvfy",
command_name: "dciodvfy",
args: vec![OsString::from("-new"), OsString::from("one.dcm")],
path: None,
required: true,
error_line_is_failure: true,
timeout: std::time::Duration::from_millis(25),
max_output_bytes: 4,
},
);
assert_eq!(check.status, ValidationStatus::Failed);
assert!(check.message.contains("capture limit"));
}
#[test]
fn validation_options_use_seconds_for_json_and_runtime_timeout() {
let options = ValidationOptions {
strict: true,
dcmvalidate_iod: Some(PathBuf::from("iod.xml")),
htj2k_decoder: Some("ojph_expand -i {input} -o {output}".to_string()),
max_pixel_frames: 3,
command_timeout_secs: 12,
max_files: 100_000,
max_depth: 64,
max_child_output_bytes: 4 * 1024 * 1024,
max_pixel_frame_bytes: 512 * 1024 * 1024,
};
let json = serde_json::to_string(&options).expect("serialize validation options");
assert!(json.contains("\"command_timeout_secs\":12"));
let options: ValidationOptions =
serde_json::from_str(&json).expect("deserialize validation options");
assert!(options.strict);
assert_eq!(options.command_timeout(), Duration::from_secs(12));
assert_eq!(options.max_pixel_frames, 3);
}
#[test]
fn doctor_reports_missing_tools_without_failing_non_strict_runs() {
let report =
doctor_dicom_environment_with_runner(&DoctorOptions::default(), &FakeRunner::default());
assert!(report
.tools
.iter()
.any(|tool| { tool.name == "dciodvfy" && tool.status == DoctorStatus::Missing }));
assert!(!report.has_failures());
}
#[test]
fn doctor_fails_missing_baseline_tools_in_strict_mode() {
let report = doctor_dicom_environment_with_runner(
&DoctorOptions {
strict: true,
..DoctorOptions::default()
},
&FakeRunner::default(),
);
assert!(report
.tools
.iter()
.any(|tool| { tool.name == "dciodvfy" && tool.status == DoctorStatus::Failed }));
assert!(report.has_failures());
}
#[test]
fn doctor_runs_probe_for_found_commands() {
let report = doctor_dicom_environment_with_runner(
&DoctorOptions::default(),
&FakeRunner::default().with_command("dciodvfy"),
);
let tool = report
.tools
.iter()
.find(|tool| tool.name == "dciodvfy")
.expect("dciodvfy doctor tool");
assert_eq!(tool.status, DoctorStatus::Available);
assert_eq!(tool.command, vec!["dciodvfy", "-version"]);
assert!(tool.message.contains("probe passed"));
}
#[test]
fn doctor_fails_found_command_when_probe_fails() {
let runner = FakeRunner::default().with_command("dciodvfy").with_outcome(
"dciodvfy -version",
CommandOutcome {
success: false,
timed_out: false,
stdout: String::new(),
stderr: "bad probe".to_string(),
stdout_truncated: false,
stderr_truncated: false,
},
);
let report = doctor_dicom_environment_with_runner(&DoctorOptions::default(), &runner);
assert!(report.tools.iter().any(|tool| {
tool.name == "dciodvfy"
&& tool.status == DoctorStatus::Failed
&& tool.message.contains("probe failed")
}));
assert!(report.has_failures());
}
#[test]
fn doctor_accepts_openjpeg_help_output_when_it_exits_nonzero() {
let runner = FakeRunner::default()
.with_command("opj_decompress")
.with_outcome(
"opj_decompress -h",
CommandOutcome {
success: false,
timed_out: false,
stdout: "This is the opj_decompress utility from the OpenJPEG project."
.to_string(),
stderr: String::new(),
stdout_truncated: false,
stderr_truncated: false,
},
);
let report = doctor_dicom_environment_with_runner(&DoctorOptions::default(), &runner);
assert!(report.tools.iter().any(|tool| {
tool.name == "opj_decompress" && tool.status == DoctorStatus::Available
}));
}
#[test]
fn doctor_parses_configured_htj2k_decoder_template() {
let report = doctor_dicom_environment_with_runner(
&DoctorOptions {
htj2k_decoder: Some(format!(
"{ABSOLUTE_HTJ2K_DECODER} -i {{input}} -o {{output}}"
)),
..DoctorOptions::default()
},
&FakeRunner::default().with_command(ABSOLUTE_HTJ2K_DECODER),
);
assert!(report.tools.iter().any(|tool| {
tool.name == "htj2k_decoder"
&& tool.status == DoctorStatus::Available
&& tool
.command
.first()
.is_some_and(|command| command == ABSOLUTE_HTJ2K_DECODER)
}));
}
#[test]
fn doctor_auto_detects_grok_for_htj2k_decoder() {
let report = doctor_dicom_environment_with_runner(
&DoctorOptions::default(),
&FakeRunner::default()
.with_command_path(super::AUTO_HTJ2K_DECODER_COMMAND, ABSOLUTE_GROK_DECODER),
);
assert!(report.tools.iter().any(|tool| {
tool.name == "htj2k_decoder"
&& tool.status == DoctorStatus::Available
&& !tool.required
&& tool.path.as_deref() == Some(Path::new(ABSOLUTE_GROK_DECODER))
&& tool.message.contains("auto-detected")
}));
}
#[test]
fn doctor_strict_fails_when_htj2k_decoder_is_not_configured_or_auto_detected() {
let report = doctor_dicom_environment_with_runner(
&DoctorOptions {
strict: true,
..DoctorOptions::default()
},
&FakeRunner::default(),
);
assert!(report.tools.iter().any(|tool| {
tool.name == "htj2k_decoder" && tool.required && tool.status == DoctorStatus::Failed
}));
assert!(report.has_failures());
}
#[test]
fn staged_dicom3tools_probe_requires_debug_build_or_explicit_env() {
assert!(!super::staged_dicom3tools_probe_enabled_from(false, false));
assert!(super::staged_dicom3tools_probe_enabled_from(true, false));
assert!(super::staged_dicom3tools_probe_enabled_from(false, true));
}
fn write_encapsulated_dicom(path: &Path, transfer_syntax: &str, frame: &[u8]) {
let mut object = InMemDicomObject::new_empty();
object.put(DataElement::<InMemDicomObject>::new(
tags::SOP_CLASS_UID,
VR::UI,
PrimitiveValue::from("1.2.840.10008.5.1.4.1.1.77.1.6"),
));
object.put(DataElement::<InMemDicomObject>::new(
tags::SOP_INSTANCE_UID,
VR::UI,
PrimitiveValue::from("1.2.826.0.1.3680043.10.999.200"),
));
object.put(DataElement::<InMemDicomObject>::new(
tags::ROWS,
VR::US,
PrimitiveValue::from(1u16),
));
object.put(DataElement::<InMemDicomObject>::new(
tags::COLUMNS,
VR::US,
PrimitiveValue::from(1u16),
));
object.put(DataElement::<InMemDicomObject>::new(
tags::NUMBER_OF_FRAMES,
VR::IS,
PrimitiveValue::from("1"),
));
let meta = FileMetaTableBuilder::new()
.media_storage_sop_class_uid("1.2.840.10008.5.1.4.1.1.77.1.6")
.media_storage_sop_instance_uid("1.2.826.0.1.3680043.10.999.200")
.transfer_syntax(transfer_syntax);
let file = File::create(path).expect("create DICOM");
let mut output = BufWriter::new(file);
object
.with_meta(meta)
.expect("file meta")
.write_all(&mut output)
.expect("write DICOM object");
crate::writer::write_encapsulated_pixel_data_from_frames(
&mut output,
&[u64::try_from(frame.len()).expect("frame len")],
|_, output| output.write_all(frame),
)
.expect("write pixel data");
output.flush().expect("flush DICOM");
}
}