use std::path::{Component, Path};
use crate::core::limits::{
within_range,
MAX_KEY_LEN,
MIN_KEY_LEN,
MAX_KEY_SEGMENT_LEN,
MIN_KEY_SEGMENT_LEN,
MAX_NAMESPACE_LEN,
MIN_NAMESPACE_LEN,
MAX_PROJECT_NAME_LEN,
MIN_PROJECT_NAME_LEN,
MAX_SEGMENT_COUNT,
MAX_STORE_PATH_LEN,
MAX_STORE_FILE_NAME_LEN,
MAX_VALUE_LEN,
MIN_VALUE_LEN,
};
use crate::error::{Result, ValidationError};
pub fn validate_key(input: &str) -> Result<()> {
validate_common_text("key", input, MIN_KEY_LEN, MAX_KEY_LEN)?;
reject_edge_slashes("key", input)?;
reject_empty_segments("key", input)?;
let segments: Vec<&str> = input.split('/').collect();
if segments.len() > MAX_SEGMENT_COUNT {
return Err(
ValidationError::too_long("key_segments", segments.len(), MAX_SEGMENT_COUNT).into(),
);
}
for segment in segments {
validate_segment("key", segment)?;
}
Ok(())
}
pub fn validate_namespace(input: &str) -> Result<()> {
validate_common_text("namespace", input, MIN_NAMESPACE_LEN, MAX_NAMESPACE_LEN)?;
reject_edge_slashes("namespace", input)?;
reject_empty_segments("namespace", input)?;
let segments: Vec<&str> = input.split('/').collect();
if segments.len() > MAX_SEGMENT_COUNT {
return Err(
ValidationError::too_long(
"namespace_segments",
segments.len(),
MAX_SEGMENT_COUNT,
)
.into(),
);
}
for segment in segments {
validate_segment("namespace", segment)?;
}
Ok(())
}
pub fn validate_key_leaf(input: &str) -> Result<()> {
validate_segment("key_leaf", input)
}
pub fn validate_value(input: &str) -> Result<()> {
validate_common_text("value", input, MIN_VALUE_LEN, MAX_VALUE_LEN)?;
if input.contains('\0') {
return Err(
ValidationError::invalid_format("value", "must not contain NUL bytes").into(),
);
}
Ok(())
}
pub fn validate_project_name(input: &str) -> Result<()> {
validate_common_text(
"project_name",
input,
MIN_PROJECT_NAME_LEN,
MAX_PROJECT_NAME_LEN,
)?;
for (index, ch) in input.char_indices() {
if is_project_name_char(ch) {
continue;
}
return Err(
ValidationError::InvalidCharacter {
field: "project_name",
character: ch,
index,
}
.into(),
);
}
if input.starts_with('-') || input.ends_with('-') {
return Err(
ValidationError::invalid_format(
"project_name",
"must not start or end with '-'",
)
.into(),
);
}
Ok(())
}
pub fn validate_store_path(path: &Path) -> Result<()> {
let rendered = path.to_string_lossy();
if rendered.is_empty() {
return Err(ValidationError::empty("store_path").into());
}
if rendered.len() > MAX_STORE_PATH_LEN {
return Err(
ValidationError::too_long(
"store_path",
rendered.len(),
MAX_STORE_PATH_LEN,
)
.into(),
);
}
let file_name = path.file_name().ok_or_else(|| {
ValidationError::invalid_path(
"store_path",
"path must include a file name",
)
})?;
let file_name = file_name.to_string_lossy();
if file_name.trim().is_empty() {
return Err(
ValidationError::invalid_path(
"store_path",
"file name must not be empty",
)
.into(),
);
}
if file_name.len() > MAX_STORE_FILE_NAME_LEN {
return Err(
ValidationError::too_long(
"store_file_name",
file_name.len(),
MAX_STORE_FILE_NAME_LEN,
)
.into(),
);
}
for component in path.components() {
validate_path_component(component)?;
}
Ok(())
}
fn validate_segment(field: &'static str, segment: &str) -> Result<()> {
validate_common_text(
field,
segment,
MIN_KEY_SEGMENT_LEN,
MAX_KEY_SEGMENT_LEN,
)?;
if segment == "." || segment == ".." {
return Err(
ValidationError::invalid_segment(
field,
"reserved segment is not allowed",
)
.into(),
);
}
for (index, ch) in segment.char_indices() {
if is_segment_char(ch) {
continue;
}
return Err(
ValidationError::InvalidCharacter {
field,
character: ch,
index,
}
.into(),
);
}
Ok(())
}
fn validate_common_text(
field: &'static str,
input: &str,
min_len: usize,
max_len: usize,
) -> Result<()> {
let len = input.len();
if len == 0 && min_len > 0 {
return Err(ValidationError::empty(field).into());
}
if !within_range(len, min_len, max_len) {
if len < min_len {
return Err(
ValidationError::too_short(field, len, min_len).into(),
);
}
return Err(
ValidationError::too_long(field, len, max_len).into(),
);
}
if input.contains('\0') {
return Err(
ValidationError::invalid_format(
field,
"must not contain NUL bytes",
)
.into(),
);
}
Ok(())
}
fn reject_edge_slashes(field: &'static str, input: &str) -> Result<()> {
if input.starts_with('/') || input.ends_with('/') {
return Err(
ValidationError::invalid_format(
field,
"must not start or end with '/'",
)
.into(),
);
}
Ok(())
}
fn reject_empty_segments(field: &'static str, input: &str) -> Result<()> {
if input.contains("//") {
return Err(
ValidationError::invalid_format(
field,
"must not contain empty path segments",
)
.into(),
);
}
Ok(())
}
fn validate_path_component(component: Component<'_>) -> Result<()> {
match component {
Component::ParentDir => Err(
ValidationError::invalid_path(
"store_path",
"parent traversal ('..') is not allowed",
)
.into(),
),
Component::Normal(name) => {
let rendered = name.to_string_lossy();
if rendered.trim().is_empty() {
return Err(
ValidationError::invalid_path(
"store_path",
"path segment must not be empty",
)
.into(),
);
}
if rendered == "." || rendered == ".." {
return Err(
ValidationError::invalid_path(
"store_path",
"reserved path segment is not allowed",
)
.into(),
);
}
Ok(())
}
Component::CurDir
| Component::RootDir
| Component::Prefix(_) => Ok(()),
}
}
#[must_use]
const fn is_segment_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')
}
#[must_use]
const fn is_project_name_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')
}