#![deny(unsafe_code)]
#![allow(
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::missing_const_for_fn,
clippy::must_use_candidate
)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects,
clippy::shadow_unrelated,
clippy::let_underscore_must_use,
clippy::format_push_string
)
)]
use std::{
io::{Error as IoError, Read},
num::{NonZeroUsize, TryFromIntError},
path::{Path, PathBuf},
};
use shardline_index::{LocalRecordStore, PostgresRecordStore, RecordStore, RecordTraversal};
use shardline_protocol::{
ByteRange, RepositoryProvider, ShardlineHash, TokenClaims, TokenCodecError, TokenScope,
};
use shardline_storage::{
DeleteOutcome, LocalObjectStore, LocalObjectStoreError, ObjectBody, ObjectIntegrity, ObjectKey,
ObjectKeyError, ObjectMetadata, ObjectPrefix, ObjectStore, PutOutcome, S3ObjectStore,
S3ObjectStoreConfig, S3ObjectStoreError,
};
use thiserror::Error;
pub mod auth;
pub mod protocol_support;
pub mod server_frontend;
pub trait AuthProvider: Send + Sync {
fn verify_token(&self, token: &str) -> Result<TokenClaims, AuthError>;
fn mint_token(&self, claims: &TokenClaims) -> Result<String, AuthError>;
}
#[derive(Debug, Clone)]
pub struct AuthContext {
pub claims: TokenClaims,
}
impl AuthContext {
#[must_use]
pub const fn new(claims: TokenClaims) -> Self {
Self { claims }
}
#[must_use]
pub const fn claims(&self) -> &TokenClaims {
&self.claims
}
#[must_use]
pub fn subject(&self) -> &str {
self.claims.subject()
}
#[must_use]
pub const fn scope(&self) -> TokenScope {
self.claims.scope()
}
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("invalid token")]
InvalidToken,
#[error("expired token")]
ExpiredToken,
#[error("insufficient scope")]
InsufficientScope,
#[error("provider error: {0}")]
ProviderError(String),
}
impl From<TokenCodecError> for AuthError {
fn from(error: TokenCodecError) -> Self {
match error {
TokenCodecError::Expired => Self::ExpiredToken,
TokenCodecError::InvalidSignature
| TokenCodecError::InvalidFormat
| TokenCodecError::InvalidHex(_)
| TokenCodecError::Claims(_) => Self::InvalidToken,
TokenCodecError::EmptySigningKey
| TokenCodecError::SigningKeyTooShort
| TokenCodecError::Json(_) => Self::ProviderError(error.to_string()),
}
}
}
pub fn validate_content_hash_with<E>(value: &str, error_fn: fn() -> E) -> Result<(), E> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
return Err(error_fn());
}
Ok(())
}
pub fn chunk_object_key(hash_hex: &str) -> Result<ObjectKey, ServerObjectStoreError> {
validate_content_hash_with(hash_hex, || ServerObjectStoreError::Overflow)?;
let prefix = hash_hex.get(..2).ok_or(ServerObjectStoreError::Overflow)?;
let key = format!("{prefix}/{hash_hex}");
ObjectKey::parse(&key).map_err(map_object_key_error)
}
pub fn chunk_hash_from_chunk_object_key_if_present(
key: &ObjectKey,
) -> Result<Option<&str>, ServerObjectStoreError> {
let mut segments = key.as_str().split('/');
let Some(prefix) = segments.next() else {
return Ok(None);
};
let Some(candidate_hash_hex) = segments.next() else {
return Ok(None);
};
if segments.next().is_some() {
return Ok(None);
}
if prefix.len() != 2 || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Ok(None);
}
if !candidate_hash_hex.starts_with(prefix) {
return Ok(None);
}
validate_content_hash_with(candidate_hash_hex, || {
ServerObjectStoreError::InvalidContentHash
})?;
Ok(Some(candidate_hash_hex))
}
#[must_use]
pub fn chunk_hash(bytes: &[u8]) -> ShardlineHash {
let digest = blake3::hash(bytes);
ShardlineHash::from_bytes(*digest.as_bytes())
}
#[must_use]
pub fn content_hash(
total_bytes: u64,
chunk_size: u64,
chunks: &[shardline_index::FileChunkRecord],
) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(&total_bytes.to_le_bytes());
hasher.update(&chunk_size.to_le_bytes());
for chunk in chunks {
hasher.update(chunk.hash.as_bytes());
hasher.update(&chunk.offset.to_le_bytes());
hasher.update(&chunk.length.to_le_bytes());
}
hasher.finalize().to_hex().to_string()
}
const fn map_object_key_error(error: ObjectKeyError) -> ServerObjectStoreError {
match error {
ObjectKeyError::Empty
| ObjectKeyError::UnsafePath
| ObjectKeyError::ControlCharacter
| ObjectKeyError::TooLong => ServerObjectStoreError::Overflow,
}
}
pub fn read_full_object(
store: &ServerObjectStore,
object_key: &ObjectKey,
length: u64,
) -> Result<Vec<u8>, ServerObjectStoreError> {
store.read_full_object(object_key, length)
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum InvalidLifecycleMetadataError {
#[error(
"quarantine candidate for {object_key} had delete-after {delete_after_unix_seconds} before first-seen {first_seen_unreachable_at_unix_seconds}"
)]
QuarantineCandidateDeleteBeforeFirstSeen {
object_key: String,
delete_after_unix_seconds: u64,
first_seen_unreachable_at_unix_seconds: u64,
},
#[error("quarantine candidate referenced missing object {object_key}")]
QuarantineCandidateMissingObject {
object_key: String,
},
#[error(
"quarantine candidate for {object_key} expected length {expected_length}, got {observed_length}"
)]
QuarantineCandidateLengthMismatch {
object_key: String,
expected_length: u64,
observed_length: u64,
},
#[error(
"retention hold for {object_key} had release-after {release_after_unix_seconds} before held-at {held_at_unix_seconds}"
)]
RetentionHoldReleaseBeforeHeld {
object_key: String,
release_after_unix_seconds: u64,
held_at_unix_seconds: u64,
},
#[error("active retention hold referenced missing object {object_key}")]
ActiveRetentionHoldMissingObject {
object_key: String,
},
#[error("active retention hold for {object_key} coexisted with quarantine state")]
ActiveRetentionHoldQuarantined {
object_key: String,
},
}
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
pub enum InvalidSerializedShardError {
#[error("shard parser rejected metadata")]
ParserRejectedMetadata,
#[error("native xet term had an empty or inverted chunk range")]
NativeXetTermEmptyOrInvertedChunkRange,
#[error("native xet term range exceeded xorb chunk count")]
NativeXetTermRangeExceededXorbChunkCount,
#[error("shard file term had an empty or inverted chunk range")]
ShardFileTermEmptyOrInvertedChunkRange,
#[error("xorb metadata cache insertion failed")]
XorbMetadataCacheInsertionFailed,
#[error("shard term chunk range started past the xorb chunk list")]
ShardTermRangeStartedPastXorbChunkList,
#[error("shard term chunk range ended past the xorb chunk list")]
ShardTermRangeEndedPastXorbChunkList,
#[error("retained shard chunk hashes were not strictly ordered")]
RetainedShardChunkHashesNotStrictlyOrdered,
}
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
pub enum InvalidReconstructionResponseError {
#[error("global latest-record walk attempted")]
RecordStoreGlobalLatestWalkAttempted,
#[error("record not found")]
RecordStoreRecordNotFound,
#[error("response term count exceeded record chunk count")]
TermCountExceededRecordChunkCount,
#[error("response term had zero unpacked length")]
TermHadZeroUnpackedLength,
#[error("response term had an empty chunk range")]
TermHadEmptyChunkRange,
#[error("response term did not have matching fetch info")]
TermMissingFetchInfo,
#[error("response fetch info contained an empty fetch list")]
EmptyFetchList,
#[error("response fetch URL did not match its xorb hash")]
FetchUrlHashMismatch,
#[error("response fetch entry had an empty chunk range")]
FetchEntryEmptyChunkRange,
#[error("response fetch entry had an inverted byte range")]
FetchEntryInvertedByteRange,
#[error("response fetch entry did not have a matching term")]
FetchEntryMissingTerm,
#[error("v2 response changed offset_into_first_range")]
V2ChangedOffsetIntoFirstRange,
#[error("v2 response changed reconstruction terms")]
V2ChangedTerms,
#[error("v2 response changed xorb fetch-info cardinality")]
V2ChangedXorbFetchInfoCardinality,
#[error("v2 response emitted a fetch hash absent from v1")]
V2FetchHashAbsentFromV1,
#[error("v2 response emitted an empty fetch list")]
V2EmptyFetchList,
#[error("v2 response emitted a fetch entry without ranges")]
V2FetchEntryWithoutRanges,
#[error("v2 response emitted an empty chunk range")]
V2EmptyChunkRange,
#[error("v2 response emitted an inverted byte range")]
V2InvertedByteRange,
#[error("v2 response fetch count disagreed with v1")]
V2FetchCountDisagreedWithV1,
#[error("v2 response range count disagreed with v1")]
V2RangeCountDisagreedWithV1,
}
pub const DEFAULT_MAX_SHARD_FILES: NonZeroUsize = match NonZeroUsize::new(16_384) {
Some(value) => value,
None => NonZeroUsize::MIN,
};
pub const DEFAULT_MAX_SHARD_XORBS: NonZeroUsize = match NonZeroUsize::new(16_384) {
Some(value) => value,
None => NonZeroUsize::MIN,
};
pub const DEFAULT_MAX_SHARD_RECONSTRUCTION_TERMS: NonZeroUsize = match NonZeroUsize::new(65_536) {
Some(value) => value,
None => NonZeroUsize::MIN,
};
pub const DEFAULT_MAX_SHARD_XORB_CHUNKS: NonZeroUsize = match NonZeroUsize::new(65_536) {
Some(value) => value,
None => NonZeroUsize::MIN,
};
pub const DEFAULT_SHARD_METADATA_LIMITS: ShardMetadataLimits = ShardMetadataLimits::new(
DEFAULT_MAX_SHARD_FILES,
DEFAULT_MAX_SHARD_XORBS,
DEFAULT_MAX_SHARD_RECONSTRUCTION_TERMS,
DEFAULT_MAX_SHARD_XORB_CHUNKS,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShardMetadataLimits {
max_files: NonZeroUsize,
max_xorbs: NonZeroUsize,
max_reconstruction_terms: NonZeroUsize,
max_xorb_chunks: NonZeroUsize,
}
impl ShardMetadataLimits {
#[must_use]
pub const fn new(
max_files: NonZeroUsize,
max_xorbs: NonZeroUsize,
max_reconstruction_terms: NonZeroUsize,
max_xorb_chunks: NonZeroUsize,
) -> Self {
Self {
max_files,
max_xorbs,
max_reconstruction_terms,
max_xorb_chunks,
}
}
#[must_use]
pub const fn max_files(self) -> NonZeroUsize {
self.max_files
}
#[must_use]
pub const fn max_xorbs(self) -> NonZeroUsize {
self.max_xorbs
}
#[must_use]
pub const fn max_reconstruction_terms(self) -> NonZeroUsize {
self.max_reconstruction_terms
}
#[must_use]
pub const fn max_xorb_chunks(self) -> NonZeroUsize {
self.max_xorb_chunks
}
}
impl Default for ShardMetadataLimits {
fn default() -> Self {
DEFAULT_SHARD_METADATA_LIMITS
}
}
#[derive(Debug, Error)]
pub enum ServerObjectStoreError {
#[error("content not found")]
NotFound,
#[error("arithmetic overflow")]
Overflow,
#[error("content hash must be 64 hexadecimal characters")]
InvalidContentHash,
#[error("stored object length did not match indexed metadata")]
StoredObjectLengthMismatch,
#[error("local storage operation failed")]
Local(#[from] LocalObjectStoreError),
#[error("s3 object storage operation failed")]
S3(#[from] S3ObjectStoreError),
#[error("local storage io failed")]
Io(#[from] IoError),
#[error("numeric conversion exceeded supported bounds")]
NumericConversion(#[from] TryFromIntError),
}
#[derive(Debug, Clone)]
pub enum ServerObjectStore {
Local(LocalObjectStore),
S3(S3ObjectStore),
Blackhole,
}
impl ObjectStore for ServerObjectStore {
type Error = ServerObjectStoreError;
fn put_if_absent(
&self,
key: &ObjectKey,
body: ObjectBody<'_>,
integrity: &ObjectIntegrity,
) -> Result<PutOutcome, Self::Error> {
match self {
Self::Local(store) => Ok(store.put_if_absent(key, body, integrity)?),
Self::S3(store) => Ok(store.put_if_absent(key, body, integrity)?),
Self::Blackhole => Ok(PutOutcome::Inserted),
}
}
fn read_range(&self, key: &ObjectKey, range: ByteRange) -> Result<Vec<u8>, Self::Error> {
match self {
Self::Local(store) => Ok(store.read_range(key, range)?),
Self::S3(store) => Ok(store.read_range(key, range)?),
Self::Blackhole => Err(ServerObjectStoreError::NotFound),
}
}
fn contains(&self, key: &ObjectKey) -> Result<bool, Self::Error> {
match self {
Self::Local(store) => Ok(store.contains(key)?),
Self::S3(store) => Ok(store.contains(key)?),
Self::Blackhole => Ok(false),
}
}
fn metadata(&self, key: &ObjectKey) -> Result<Option<ObjectMetadata>, Self::Error> {
match self {
Self::Local(store) => Ok(store.metadata(key)?),
Self::S3(store) => Ok(store.metadata(key)?),
Self::Blackhole => Ok(None),
}
}
fn list_prefix(&self, prefix: &ObjectPrefix) -> Result<Vec<ObjectMetadata>, Self::Error> {
match self {
Self::Local(store) => Ok(store.list_prefix(prefix)?),
Self::S3(store) => Ok(store.list_prefix(prefix)?),
Self::Blackhole => Ok(Vec::new()),
}
}
fn delete_if_present(&self, key: &ObjectKey) -> Result<DeleteOutcome, Self::Error> {
match self {
Self::Local(store) => Ok(store.delete_if_present(key)?),
Self::S3(store) => Ok(store.delete_if_present(key)?),
Self::Blackhole => Ok(DeleteOutcome::NotFound),
}
}
}
impl ServerObjectStore {
pub fn local(root: impl Into<PathBuf>) -> Result<Self, ServerObjectStoreError> {
Ok(Self::Local(LocalObjectStore::new(root.into())?))
}
pub fn s3(config: S3ObjectStoreConfig) -> Result<Self, ServerObjectStoreError> {
Ok(Self::S3(S3ObjectStore::new(config)?))
}
#[must_use]
pub const fn blackhole() -> Self {
Self::Blackhole
}
pub fn put_overwrite(
&self,
key: &ObjectKey,
body: ObjectBody<'_>,
integrity: &ObjectIntegrity,
) -> Result<(), ServerObjectStoreError> {
match self {
Self::Local(store) => store
.put_overwrite(key, body, integrity)
.map_err(Into::into),
Self::S3(store) => store
.put_overwrite(key, body, integrity)
.map_err(Into::into),
Self::Blackhole => Ok(()),
}
}
pub fn visit_prefix<F, E>(&self, prefix: &ObjectPrefix, mut visitor: F) -> Result<(), E>
where
F: FnMut(ObjectMetadata) -> Result<(), E>,
E: From<LocalObjectStoreError> + From<S3ObjectStoreError>,
{
match self {
Self::Local(store) => store.visit_prefix(prefix, &mut visitor),
Self::S3(store) => store.visit_prefix(prefix, &mut visitor),
Self::Blackhole => Ok(()),
}
}
pub fn list_flat_namespace_page(
&self,
prefix: &ObjectPrefix,
start_after: Option<&ObjectKey>,
limit: usize,
) -> Result<Vec<ObjectMetadata>, ServerObjectStoreError> {
match self {
Self::Local(store) => store
.list_flat_namespace_page(prefix, start_after, limit)
.map_err(Into::into),
Self::S3(store) => store
.list_flat_namespace_page(prefix, start_after, limit)
.map_err(Into::into),
Self::Blackhole => Ok(Vec::new()),
}
}
#[must_use]
pub fn local_path_for_key(&self, key: &ObjectKey) -> Option<PathBuf> {
match self {
Self::Local(store) => Some(store.path_for_key(key)),
Self::S3(_store) => None,
Self::Blackhole => None,
}
}
pub fn copy_if_absent(
&self,
source: &ObjectKey,
destination: &ObjectKey,
) -> Result<PutOutcome, ServerObjectStoreError> {
match self {
Self::Local(store) => store
.copy_object_if_absent(source, destination)
.map_err(Into::into),
Self::S3(store) => store
.copy_object_if_absent(source, destination)
.map_err(Into::into),
Self::Blackhole => Err(ServerObjectStoreError::NotFound),
}
}
pub fn put_content_addressed_file(
&self,
key: &ObjectKey,
path: &Path,
integrity: &ObjectIntegrity,
) -> Result<PutOutcome, ServerObjectStoreError> {
match self {
Self::Local(store) => store
.put_temporary_file_if_absent(key, path, integrity)
.map_err(Into::into),
Self::S3(store) => store
.put_content_addressed_file(key, path, integrity)
.map_err(Into::into),
Self::Blackhole => Ok(PutOutcome::Inserted),
}
}
#[must_use]
pub fn local_root(&self) -> Option<&Path> {
match self {
Self::Local(store) => Some(store.root()),
Self::S3(_store) => None,
Self::Blackhole => None,
}
}
#[must_use]
pub const fn backend_name(&self) -> &'static str {
match self {
Self::Local(_store) => "local",
Self::S3(_store) => "s3",
Self::Blackhole => "blackhole",
}
}
pub fn read_full_object(
&self,
object_key: &ObjectKey,
length: u64,
) -> Result<Vec<u8>, ServerObjectStoreError> {
if length == 0 {
return Ok(Vec::new());
}
if let Self::Local(store) = self {
let file = store.open_object_file(object_key)?;
let actual_length = file.metadata()?.len();
if actual_length != length {
return Err(ServerObjectStoreError::StoredObjectLengthMismatch);
}
let capacity = usize::try_from(length)?;
let mut output = Vec::with_capacity(capacity);
let mut limited = file.take(length);
Read::read_to_end(&mut limited, &mut output)?;
if output.len() != capacity {
return Err(ServerObjectStoreError::StoredObjectLengthMismatch);
}
return Ok(output);
}
let end = length
.checked_sub(1)
.ok_or(ServerObjectStoreError::Overflow)?;
let range = ByteRange::new(0, end).map_err(|_error| ServerObjectStoreError::Overflow)?;
self.read_range(object_key, range)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpsRecordKind {
Latest,
Version,
}
pub trait OpsRecordStore: RecordStore {
fn locator_display(&self, locator: &<Self as RecordTraversal>::Locator) -> String;
fn locator_file_id(
&self,
locator: &<Self as RecordTraversal>::Locator,
kind: OpsRecordKind,
) -> Option<String>;
fn locator_content_hash(
&self,
locator: &<Self as RecordTraversal>::Locator,
kind: OpsRecordKind,
) -> Option<String>;
}
impl OpsRecordStore for LocalRecordStore {
fn locator_display(&self, locator: &<Self as RecordTraversal>::Locator) -> String {
locator.record_key().to_owned()
}
fn locator_file_id(
&self,
locator: &<Self as RecordTraversal>::Locator,
_kind: OpsRecordKind,
) -> Option<String> {
Some(locator.file_id().to_owned())
}
fn locator_content_hash(
&self,
locator: &<Self as RecordTraversal>::Locator,
kind: OpsRecordKind,
) -> Option<String> {
if kind != OpsRecordKind::Version {
return None;
}
locator.content_hash().map(ToOwned::to_owned)
}
}
impl OpsRecordStore for PostgresRecordStore {
fn locator_display(&self, locator: &<Self as RecordTraversal>::Locator) -> String {
locator.record_key().to_owned()
}
fn locator_file_id(
&self,
locator: &<Self as RecordTraversal>::Locator,
_kind: OpsRecordKind,
) -> Option<String> {
Some(locator.file_id().to_owned())
}
fn locator_content_hash(
&self,
locator: &<Self as RecordTraversal>::Locator,
kind: OpsRecordKind,
) -> Option<String> {
if kind != OpsRecordKind::Version {
return None;
}
locator.content_hash().map(ToOwned::to_owned)
}
}
pub const MAX_LOCAL_RECORD_METADATA_BYTES: u64 = 1_073_741_824;
pub fn parse_stored_file_record_bytes(
bytes: &[u8],
) -> Result<shardline_index::FileRecord, ParseStoredFileRecordError> {
let observed_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if observed_bytes > MAX_LOCAL_RECORD_METADATA_BYTES {
return Err(ParseStoredFileRecordError::StoredFileMetadataTooLarge {
observed_bytes,
maximum_bytes: MAX_LOCAL_RECORD_METADATA_BYTES,
});
}
Ok(serde_json::from_slice(bytes)?)
}
#[derive(Debug, Error)]
pub enum ParseStoredFileRecordError {
#[error("stored file metadata exceeded the bounded parser ceiling")]
StoredFileMetadataTooLarge {
observed_bytes: u64,
maximum_bytes: u64,
},
#[error("json operation failed")]
Json(#[from] serde_json::Error),
}
#[must_use]
pub const fn provider_directory(provider: RepositoryProvider) -> &'static str {
provider.as_str()
}
const MAX_IDENTIFIER_BYTES: usize = 1024;
pub fn validate_identifier(value: &str) -> Result<(), ValidateIdentifierError> {
if value.trim().is_empty()
|| value == "."
|| value.len() > MAX_IDENTIFIER_BYTES
|| value.starts_with('/')
|| value.contains("..")
|| value.contains('\\')
|| value.contains('/')
|| value.chars().any(char::is_control)
{
return Err(ValidateIdentifierError);
}
Ok(())
}
#[derive(Debug, Clone, Copy, Error)]
#[error("file identifier must be relative and must not contain traversal or control characters")]
pub struct ValidateIdentifierError;
pub fn validate_content_hash(value: &str) -> Result<(), ValidateContentHashError> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
return Err(ValidateContentHashError);
}
Ok(())
}
#[derive(Debug, Clone, Copy, Error)]
#[error("content hash must be 64 hexadecimal characters")]
pub struct ValidateContentHashError;
pub const fn checked_add(left: u64, right: u64) -> Result<u64, RebuildOverflowError> {
match left.checked_add(right) {
Some(value) => Ok(value),
None => Err(RebuildOverflowError),
}
}
pub const fn checked_increment(value: u64) -> Result<u64, RebuildOverflowError> {
checked_add(value, 1)
}
#[derive(Debug, Clone, Copy, Error)]
#[error("arithmetic overflow")]
pub struct RebuildOverflowError;
pub fn unix_now_seconds_checked() -> Result<u64, RebuildOverflowError> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|_e| RebuildOverflowError)
}
pub const DEFAULT_LOCAL_GC_RETENTION_SECONDS: u64 = 86_400;
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn validate_identifier_accepts_simple_name() {
assert!(validate_identifier("hello.txt").is_ok());
}
#[test]
fn validate_identifier_accepts_dotted_name() {
assert!(validate_identifier("file.name.txt").is_ok());
}
#[test]
fn validate_identifier_rejects_empty() {
assert!(validate_identifier("").is_err());
}
#[test]
fn validate_identifier_rejects_whitespace_only() {
assert!(validate_identifier(" ").is_err());
}
#[test]
fn validate_identifier_rejects_dot() {
assert!(validate_identifier(".").is_err());
}
#[test]
fn validate_identifier_rejects_leading_slash() {
assert!(validate_identifier("/etc/passwd").is_err());
}
#[test]
fn validate_identifier_rejects_traversal() {
assert!(validate_identifier("foo/../bar").is_err());
}
#[test]
fn validate_identifier_rejects_backslash() {
assert!(validate_identifier("foo\\bar").is_err());
}
#[test]
fn validate_identifier_rejects_control_char() {
assert!(validate_identifier("foo\tbar").is_err());
}
#[test]
fn validate_content_hash_accepts_valid_hash() {
let hash = "a".repeat(64);
assert!(validate_content_hash(&hash).is_ok());
}
#[test]
fn validate_content_hash_rejects_too_short() {
assert!(validate_content_hash("abc123").is_err());
}
#[test]
fn validate_content_hash_rejects_too_long() {
let hash = "a".repeat(65);
assert!(validate_content_hash(&hash).is_err());
}
#[test]
fn validate_content_hash_rejects_uppercase() {
let hash = "A".repeat(64);
assert!(validate_content_hash(&hash).is_err());
}
#[test]
fn validate_content_hash_rejects_non_hex() {
let mut hash = "a".repeat(64);
hash.push('g');
hash.remove(0);
assert!(validate_content_hash(&hash).is_err());
}
#[test]
fn checked_add_normal() {
assert_eq!(checked_add(1, 2).unwrap(), 3);
}
#[test]
fn checked_add_zero() {
assert_eq!(checked_add(0, 0).unwrap(), 0);
}
#[test]
fn checked_add_overflow() {
assert!(checked_add(u64::MAX, 1).is_err());
}
#[test]
fn checked_increment_normal() {
assert_eq!(checked_increment(0).unwrap(), 1);
}
#[test]
fn checked_increment_overflow() {
assert!(checked_increment(u64::MAX).is_err());
}
#[test]
fn chunk_object_key_valid() {
let hash = "a".repeat(64);
let key = chunk_object_key(&hash).unwrap();
assert!(key.as_str().starts_with("aa/"));
assert!(key.as_str().ends_with(&hash));
}
#[test]
fn chunk_object_key_invalid_hash() {
assert!(chunk_object_key("short").is_err());
}
#[test]
fn parse_stored_file_record_bytes_valid() {
let json = r#"{"file_id":"test.txt","content_hash":"aabb","total_bytes":100,"chunk_size":10,"chunks":[]}"#;
assert!(parse_stored_file_record_bytes(json.as_bytes()).is_ok());
}
#[test]
fn parse_stored_file_record_bytes_invalid_json() {
assert!(parse_stored_file_record_bytes(b"not json").is_err());
}
#[test]
fn parse_stored_file_record_bytes_oversized() {
let valid =
r#"{"file_id":"test","content_hash":"aa","total_bytes":0,"chunk_size":0,"chunks":[]}"#;
assert!(parse_stored_file_record_bytes(valid.as_bytes()).is_ok());
let oversized = vec![0u8; (MAX_LOCAL_RECORD_METADATA_BYTES + 1) as usize];
assert!(parse_stored_file_record_bytes(&oversized).is_err());
}
proptest::proptest! {
#[test]
fn proptest_validate_identifier_rejects_leading_slash(s in "[a-z]{1,100}") {
let input = format!("/{s}");
prop_assert!(validate_identifier(&input).is_err(), "leading slash should be rejected: {input:?}");
}
#[test]
fn proptest_validate_identifier_rejects_traversal(s in "[a-z]{1,50}") {
let input = format!("{s}/../{s}");
prop_assert!(validate_identifier(&input).is_err(), "traversal should be rejected: {input:?}");
}
#[test]
fn proptest_validate_identifier_rejects_backslash(s in "[a-z]{1,50}") {
let input = format!("{s}\\{s}");
prop_assert!(validate_identifier(&input).is_err(), "backslash should be rejected: {input:?}");
}
#[test]
fn proptest_validate_identifier_accepts_valid_names(segs in prop::collection::vec("[a-z]{1,20}", 1..3usize)) {
let input = segs.join(".");
let result = validate_identifier(&input);
prop_assert!(result.is_ok(), "valid identifier should be accepted: {input:?}");
}
#[test]
fn proptest_validate_identifier_rejects_control_characters(segs in prop::collection::vec("[a-z]{1,20}", 1..3usize)) {
let mut input = segs.join(".");
input.push('\t');
let result = validate_identifier(&input);
prop_assert!(result.is_err(), "control characters should be rejected: {input:?}");
}
}
}