use crate::hex::{hex_encode_bytes, is_lower_hex_byte};
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
const SERVER_GENERATED_ID_BODY_LEN: usize = 32;
macro_rules! validation_error {
($name:ident, $message:literal) => {
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error($message)]
pub struct $name {
value: String,
reason: String,
}
impl $name {
pub(crate) fn new(value: &str, reason: impl Into<String>) -> Self {
Self {
value: value.to_owned(),
reason: reason.into(),
}
}
pub fn value(&self) -> &str {
&self.value
}
pub fn reason(&self) -> &str {
&self.reason
}
}
};
}
validation_error!(
NamespaceIdValidationError,
"invalid namespace_id {value:?}: {reason}"
);
validation_error!(
CommitIdValidationError,
"invalid commit_id {value:?}: {reason}"
);
validation_error!(
GeneratedIdValidationError,
"invalid generated id {value:?}: {reason}"
);
validation_error!(
SnapshotIdValidationError,
"invalid snapshot_id {value:?}: {reason}"
);
validation_error!(
NameKeyValidationError,
"invalid name_key {value:?}: {reason}"
);
validation_error!(
WriterIdValidationError,
"invalid writer_id {value:?}: {reason}"
);
validation_error!(
BindingGenerationValidationError,
"invalid binding_generation {value:?}: {reason}"
);
macro_rules! string_id {
(
$(#[$meta:meta])*
$name:ident,
error = $error:ty,
validate = $validate:expr
$(, schema($($schema:tt)+))?
$(,)?
) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(
feature = "openapi",
schema(value_type = String $(, $($schema)+)?)
)]
pub struct $name(String);
impl $name {
pub fn parse(value: impl AsRef<str>) -> Result<Self, $error> {
let value = value.as_ref();
($validate)(value)?;
Ok(Self(value.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<&str> for $name {
type Error = $error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl TryFrom<String> for $name {
type Error = $error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl std::str::FromStr for $name {
type Err = $error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl std::borrow::Borrow<str> for $name {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl serde::Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
};
(
$(#[$meta:meta])*
$name:ident,
prefix = $prefix:literal
$(, schema($($schema:tt)+))?
$(,)?
) => {
string_id! {
$(#[$meta])*
$name,
error = GeneratedIdValidationError,
validate = |value: &str| validate_generated_id($prefix, value)
$(, schema($($schema)+))?
}
impl $name {
pub fn generate() -> Self {
Self(generated_id($prefix))
}
}
};
}
macro_rules! numeric_id {
(
$(#[$meta:meta])*
$name:ident,
public_ordinal,
schema_description = $schema_description:literal
) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
pub struct $name(pub u64);
impl $name {
pub fn parse(value: u64) -> Result<Self, $crate::PublicOrdinalRangeError> {
if value > $crate::MAX_PUBLIC_INTEGER {
return Err($crate::PublicOrdinalRangeError);
}
Ok(Self(value))
}
pub fn successor(self) -> Result<Self, $crate::PublicOrdinalRangeError> {
$crate::next_public_ordinal(self.0)
.map(Self)
.ok_or($crate::PublicOrdinalRangeError)
}
}
#[cfg(feature = "openapi")]
impl utoipa::PartialSchema for $name {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::Object::builder()
.schema_type(utoipa::openapi::schema::Type::Integer)
.format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
utoipa::openapi::KnownFormat::Int64,
)))
.minimum(Some(0u64))
.maximum(Some($crate::MAX_PUBLIC_INTEGER))
.description(Some($schema_description))
.into()
}
}
#[cfg(feature = "openapi")]
impl utoipa::ToSchema for $name {}
impl<'de> serde::Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
impl From<u64> for $name {
fn from(value: u64) -> Self {
Self(value)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
};
(
$(#[$meta:meta])*
$name:ident
) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct $name(pub u64);
impl From<u64> for $name {
fn from(value: u64) -> Self {
Self(value)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
pub(crate) use numeric_id;
pub(crate) use string_id;
pub(crate) use validation_error;
pub fn generated_id(prefix: &'static str) -> String {
format!("{prefix}_{}", hex_encode_bytes(&random_128()))
}
fn random_128() -> [u8; 16] {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes).expect("the system random generator must be available");
bytes
}
fn validate_generated_id(
prefix: &'static str,
value: &str,
) -> Result<(), GeneratedIdValidationError> {
let expected_prefix = format!("{prefix}_");
let Some(body) = value.strip_prefix(&expected_prefix) else {
return Err(GeneratedIdValidationError::new(
value,
format!("must start with `{expected_prefix}`"),
));
};
if body.len() != SERVER_GENERATED_ID_BODY_LEN {
return Err(GeneratedIdValidationError::new(
value,
format!("body must be {SERVER_GENERATED_ID_BODY_LEN} lowercase hex characters"),
));
}
if !body.bytes().all(is_lower_hex_byte) {
return Err(GeneratedIdValidationError::new(
value,
"body must contain only lowercase hex characters".to_owned(),
));
}
Ok(())
}
fn validate_namespace_id(value: &str) -> Result<(), NamespaceIdValidationError> {
validate_id_grammar(value).map_err(|reason| NamespaceIdValidationError::new(value, reason))?;
if value.starts_with("loonfs-") {
return Err(NamespaceIdValidationError::new(
value,
"the `loonfs-` prefix is reserved for LoonFS system namespaces",
));
}
Ok(())
}
fn validate_commit_id(value: &str) -> Result<(), CommitIdValidationError> {
validate_id_grammar(value).map_err(|reason| CommitIdValidationError::new(value, reason))
}
pub const MAX_NAME_KEY_BYTES: usize = 768;
pub const MAX_ID_BYTES: usize = 128;
fn validate_name_key(value: &str) -> Result<(), NameKeyValidationError> {
if value.is_empty() {
return Err(NameKeyValidationError::new(value, "must not be empty"));
}
if value.contains('/') {
return Err(NameKeyValidationError::new(value, "must not contain `/`"));
}
if matches!(value, "." | "..") {
return Err(NameKeyValidationError::new(
value,
"must not be `.` or `..`",
));
}
if value.chars().any(|character| character.is_control()) {
return Err(NameKeyValidationError::new(
value,
"must not contain control characters",
));
}
if value.len() > MAX_NAME_KEY_BYTES {
return Err(NameKeyValidationError::new(
"",
format!("exceeds the maximum name key length of {MAX_NAME_KEY_BYTES} bytes"),
));
}
Ok(())
}
fn validate_id_grammar(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("must not be empty".to_owned());
}
if value.len() > MAX_ID_BYTES {
return Err(format!("must be {MAX_ID_BYTES} bytes or fewer"));
}
if value.trim() != value {
return Err("must not have leading or trailing whitespace".to_owned());
}
if matches!(value, "." | "..") {
return Err("must not be `.` or `..`".to_owned());
}
let mut chars = value.chars();
let first = chars
.next()
.expect("empty id returned before char validation");
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return Err("must start with a lowercase ASCII letter or digit".to_owned());
}
if !chars.all(is_allowed_id_tail_char) {
return Err(
"must contain only lowercase ASCII letters, digits, `.`, `_`, or `-`".to_owned(),
);
}
Ok(())
}
fn is_allowed_id_tail_char(ch: char) -> bool {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-')
}
string_id! {
NamespaceId,
error = NamespaceIdValidationError,
validate = validate_namespace_id,
schema(
pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
example = "demo"
)
}
string_id! {
WriterId,
error = WriterIdValidationError,
validate = |value: &str| {
if value.trim().is_empty() {
return Err(WriterIdValidationError::new(value, "must not be blank"));
}
Ok(())
}
}
string_id! {
BindingGeneration,
error = BindingGenerationValidationError,
validate = |value: &str| {
if value.is_empty() {
return Err(BindingGenerationValidationError::new(value, "must not be empty"));
}
if !value.bytes().all(is_lower_hex_byte) {
return Err(BindingGenerationValidationError::new(
value,
"must contain only lowercase hex characters",
));
}
Ok(())
},
schema(pattern = r"^[0-9a-f]+$")
}
string_id! {
ContentStoreId,
prefix = "cs"
}
string_id! {
CommitId,
error = CommitIdValidationError,
validate = validate_commit_id,
schema(
pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
example = "c_f3a9c2d4b6e8417a90c5d2f8e1b7a6c0"
)
}
impl CommitId {
pub fn generate() -> Self {
Self(generated_id("c"))
}
}
string_id! {
CheckpointId,
error = GeneratedIdValidationError,
validate = validate_checkpoint_id,
schema(
pattern = r"^pin_[0-9]{20}-[0-9a-f]{16}$",
example = "pin_00000000000000000001-0000000000000002"
)
}
string_id! {
SnapshotId,
error = SnapshotIdValidationError,
validate = |value: &str| validate_checkpoint_id(value)
.map_err(|error| SnapshotIdValidationError::new(&error.value, error.reason)),
schema(
pattern = r"^pin_[0-9]{20}-[0-9a-f]{16}$",
example = "pin_00000000000000000001-0000000000000002"
)
}
impl From<SnapshotId> for CheckpointId {
fn from(snapshot_id: SnapshotId) -> Self {
Self(snapshot_id.0)
}
}
impl From<CheckpointId> for SnapshotId {
fn from(checkpoint_id: CheckpointId) -> Self {
Self(checkpoint_id.0)
}
}
impl CheckpointId {
pub fn generate(manifest_no: ManifestNo) -> Self {
let entropy = generated_id("pin");
Self::parse(format!("pin_{:020}-{}", manifest_no.0, &entropy[4..20]))
.expect("the pinned manifest number should be valid")
}
pub fn manifest_no(&self) -> ManifestNo {
ManifestNo(
self.0[4..24]
.parse()
.expect("a pin id should contain a manifest number"),
)
}
}
fn validate_checkpoint_id(value: &str) -> Result<(), GeneratedIdValidationError> {
let valid = value
.strip_prefix("pin_")
.and_then(|body| body.split_once('-'))
.is_some_and(|(number, entropy)| {
number.len() == 20
&& number.bytes().all(|byte| byte.is_ascii_digit())
&& number
.parse::<u64>()
.ok()
.and_then(|number| ManifestNo::parse(number).ok())
.is_some_and(|number| number.0 > 0)
&& entropy.len() == 16
&& entropy.bytes().all(is_lower_hex_byte)
});
if !valid {
return Err(GeneratedIdValidationError::new(value, "must be `pin_` followed by a twenty-digit positive manifest number, `-`, and sixteen lowercase hex characters".to_owned()));
}
Ok(())
}
string_id! {
UploadId,
prefix = "upl",
schema(
pattern = r"^upl_[0-9a-f]{32}$",
example = "upl_4d8f2c91a7b34e0f9c6d1a2b3e5f708c"
)
}
string_id! {
ContentId,
prefix = "con",
schema(
pattern = r"^con_[0-9a-f]{32}$",
example = "con_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41"
)
}
impl ContentId {
pub fn shard_prefixes(&self) -> [&str; CONTENT_ID_SHARD_LEVELS] {
let first_start = CONTENT_ID_PREFIX_LEN;
let second_start = first_start + CONTENT_ID_SHARD_WIDTH;
[
&self.0[first_start..second_start],
&self.0[second_start..second_start + CONTENT_ID_SHARD_WIDTH],
]
}
}
const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
const CONTENT_ID_SHARD_LEVELS: usize = 2;
const CONTENT_ID_SHARD_WIDTH: usize = 2;
string_id! {
MetadataSegmentId,
prefix = "seg"
}
string_id! {
MetadataCompactionId,
prefix = "cmp"
}
string_id! {
IndexSegmentId,
prefix = "idx"
}
string_id! {
NameKey,
error = NameKeyValidationError,
validate = validate_name_key,
schema(example = "report.txt")
}
impl NameKey {
pub fn for_display_name(display_name: &crate::DisplayName) -> Self {
Self(crate::name_key_for_display_name(display_name.as_str()))
}
}
pub const MAX_PUBLIC_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PublicOrdinalRangeError;
impl fmt::Display for PublicOrdinalRangeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "must be an integer from 0 through {MAX_PUBLIC_INTEGER}")
}
}
impl std::error::Error for PublicOrdinalRangeError {}
pub fn next_public_ordinal(current: u64) -> Option<u64> {
current
.checked_add(1)
.filter(|next| *next <= MAX_PUBLIC_INTEGER)
}
numeric_id! {
InodeId
}
#[cfg(feature = "openapi")]
#[allow(
deprecated,
reason = "the published schema uses the requested singular example field"
)]
impl utoipa::PartialSchema for InodeId {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::Object::builder()
.schema_type(utoipa::openapi::schema::Type::String)
.pattern(Some(crate::public_inode_id::PATTERN))
.example(Some(serde_json::json!(crate::public_inode_id::EXAMPLE)))
.description(Some(crate::public_inode_id::DESCRIPTION))
.into()
}
}
#[cfg(feature = "openapi")]
impl utoipa::ToSchema for InodeId {}
pub const ROOT_INODE_ID: InodeId = InodeId(1);
pub const FIRST_ALLOCATABLE_INODE_ID: InodeId = InodeId(ROOT_INODE_ID.0 + 1);
numeric_id! {
RevisionNo,
public_ordinal,
schema_description = "Revision number for a file's content. It increases whenever the content is replaced or restored."
}
numeric_id! {
ChangeSeq,
public_ordinal,
schema_description = "Sequence number assigned to a namespace commit. It determines the order in which commits become visible."
}
numeric_id! {
WalNo,
public_ordinal,
schema_description = "Contiguous WAL object number within one namespace."
}
numeric_id! {
ManifestNo,
public_ordinal,
schema_description = "Monotonic manifest counter for one namespace. It can increase when metadata changes, even if no namespace commit is written."
}
numeric_id! {
RunNo,
public_ordinal,
schema_description = "Monotonic run counter allocated by the manifest that names the run. A run is the set of segments one producer wrote together."
}
numeric_id! {
WriterEpoch,
public_ordinal,
schema_description = "Counter used to reject writes from an older writer."
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum InodeKind {
File,
#[serde(rename = "dir")]
Directory,
}
impl InodeKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Directory => "dir",
}
}
}
impl fmt::Display for InodeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::{
next_public_ordinal, BindingGeneration, ChangeSeq, CheckpointId, CommitId, ContentId,
ContentStoreId, InodeId, ManifestNo, MetadataSegmentId, NameKey, NamespaceId, RevisionNo,
RunNo, SnapshotId, UploadId, WalNo, WriterEpoch, WriterId, MAX_PUBLIC_INTEGER,
};
use crate::AttributeRevisionNo;
use std::collections::BTreeSet;
#[test]
fn public_ordinal_advancement_accepts_the_maximum_and_rejects_the_next_value() {
assert_eq!(
next_public_ordinal(MAX_PUBLIC_INTEGER - 1),
Some(MAX_PUBLIC_INTEGER)
);
assert_eq!(next_public_ordinal(MAX_PUBLIC_INTEGER), None);
}
#[test]
fn public_ordinal_inputs_must_fit_the_json_safe_integer_range() {
macro_rules! assert_range {
($type:ty) => {{
let constructed = <$type>::parse(MAX_PUBLIC_INTEGER)
.expect("construct the maximum public ordinal");
assert_eq!(constructed.0, MAX_PUBLIC_INTEGER);
let construction_error = <$type>::parse(MAX_PUBLIC_INTEGER + 1)
.expect_err("reject a value above the public limit");
assert_eq!(
construction_error.to_string(),
"must be an integer from 0 through 9007199254740991"
);
let maximum = serde_json::from_str::<$type>(&MAX_PUBLIC_INTEGER.to_string())
.expect("deserialize the maximum public ordinal");
assert_eq!(maximum.0, MAX_PUBLIC_INTEGER);
let error = serde_json::from_str::<$type>(&(MAX_PUBLIC_INTEGER + 1).to_string())
.expect_err("ordinal above the public range");
assert!(
error
.to_string()
.contains("must be an integer from 0 through 9007199254740991"),
"unexpected range error: {error}"
);
}};
}
assert_range!(RevisionNo);
assert_range!(ChangeSeq);
assert_range!(AttributeRevisionNo);
assert_range!(ManifestNo);
assert_range!(WalNo);
assert_range!(RunNo);
assert_range!(WriterEpoch);
assert_eq!(
serde_json::from_str::<InodeId>(&(MAX_PUBLIC_INTEGER + 1).to_string())
.expect("inode ids retain the full u64 range"),
InodeId(MAX_PUBLIC_INTEGER + 1)
);
}
#[test]
fn namespace_id_parse_accepts_allowed_grammar() {
let long_id = format!("a{}", "b".repeat(127));
for value in ["demo", "demo-1", "demo_1", "demo.v1", &long_id] {
let parsed = NamespaceId::parse(value).expect("valid namespace_id");
assert_eq!(parsed.as_str(), value);
}
}
#[test]
fn namespace_id_parse_rejects_invalid_values() {
let long_id = format!("a{}", "b".repeat(128));
for value in [
"", "/", "a/b", ".", "..", " demo", "demo ", "demo\n", "demo?", "demo#", "demo%",
"Demo", &long_id,
] {
assert!(
NamespaceId::parse(value).is_err(),
"expected invalid namespace_id {value:?}"
);
}
}
#[test]
fn namespace_id_parse_rejects_reserved_system_prefix() {
assert!(NamespaceId::parse("loonfs-doctor-abc").is_err());
assert!(NamespaceId::parse("loonfs-").is_err());
assert_eq!(
NamespaceId::parse("my-loonfs-notes")
.expect("non-prefixed use is allowed")
.as_str(),
"my-loonfs-notes"
);
assert!(CommitId::parse("loonfs-retry-1").is_ok());
}
#[test]
fn writer_id_rejects_blank_text() {
for value in ["", " ", "\n", " \t "] {
assert!(WriterId::parse(value).is_err(), "accepted {value:?}");
}
}
#[test]
fn binding_generation_requires_nonempty_lowercase_hex() {
for value in ["", "abcg", "ABC", "01-23"] {
assert!(
BindingGeneration::parse(value).is_err(),
"accepted {value:?}"
);
}
for value in ["0", "0123456789abcdef"] {
assert_eq!(
BindingGeneration::parse(value)
.expect("valid binding generation")
.as_str(),
value
);
}
}
#[test]
fn identity_try_from_validates_values() {
assert_eq!(
NamespaceId::try_from("demo")
.expect("valid namespace id")
.as_str(),
"demo"
);
assert_eq!(
CommitId::try_from("commit-1")
.expect("valid commit id")
.as_str(),
"commit-1"
);
assert_eq!(
ContentStoreId::try_from("cs_00000000000000000000000000000001")
.expect("valid content store id")
.as_str(),
"cs_00000000000000000000000000000001"
);
assert_eq!(
CheckpointId::try_from("pin_00000000000000000001-0000000000000001")
.expect("valid checkpoint id")
.as_str(),
"pin_00000000000000000001-0000000000000001"
);
assert_eq!(
NameKey::try_from("report.txt".to_owned())
.expect("valid name key")
.as_str(),
"report.txt"
);
assert!(NamespaceId::try_from("invalid/name").is_err());
assert!(CommitId::try_from("invalid/name").is_err());
assert!(ContentStoreId::try_from("cs_0000000000000000000000000000000g").is_err());
assert!(CheckpointId::try_from("chk_0000000000000000000000000000000g").is_err());
assert!(NameKey::try_from("a/b").is_err());
}
#[test]
fn identity_deserialize_validates_values() {
let namespace_id: NamespaceId =
serde_json::from_str(r#""demo""#).expect("valid namespace id json");
assert_eq!(namespace_id.as_str(), "demo");
let commit_id: CommitId =
serde_json::from_str(r#""commit-1""#).expect("valid commit id json");
assert_eq!(commit_id.as_str(), "commit-1");
let content_store_id: ContentStoreId =
serde_json::from_str(r#""cs_00000000000000000000000000000001""#)
.expect("valid content store id json");
assert_eq!(
content_store_id.as_str(),
"cs_00000000000000000000000000000001"
);
let checkpoint_id: CheckpointId =
serde_json::from_str(r#""pin_00000000000000000001-0000000000000001""#)
.expect("valid checkpoint id json");
assert_eq!(
checkpoint_id.as_str(),
"pin_00000000000000000001-0000000000000001"
);
let namespace_error = serde_json::from_str::<NamespaceId>(r#""invalid/name""#)
.expect_err("invalid namespace id json");
assert!(namespace_error.to_string().contains("namespace_id"));
let commit_error = serde_json::from_str::<CommitId>(r#""invalid/name""#)
.expect_err("invalid commit id json");
assert!(commit_error.to_string().contains("commit_id"));
let content_store_error =
serde_json::from_str::<ContentStoreId>(r#""cs_0000000000000000000000000000000g""#)
.expect_err("invalid content store id json");
assert!(content_store_error.to_string().contains("generated id"));
let checkpoint_error =
serde_json::from_str::<CheckpointId>(r#""chk_0000000000000000000000000000000g""#)
.expect_err("invalid checkpoint id json");
assert!(checkpoint_error.to_string().contains("generated id"));
}
#[test]
fn generated_content_store_id_parse_requires_prefix_and_lower_hex_body() {
let parsed = ContentStoreId::parse("cs_00000000000000000000000000000001")
.expect("valid content store id");
assert_eq!(parsed.as_str(), "cs_00000000000000000000000000000001");
let hyphenated_content_store_id = ["cs", "1"].join("-");
for value in [
hyphenated_content_store_id.as_str(),
"upl_00000000000000000000000000000001",
"content-stores/foo",
"cs_",
"cs_abcdef",
"cs_0000000000000000000000000000000",
"cs_000000000000000000000000000000001",
"cs_ABCDEF00000000000000000000000000",
"cs_0000000000000000000000000000000g",
" cs_00000000000000000000000000000001",
"cs_00000000000000000000000000000001 ",
] {
assert!(
ContentStoreId::parse(value).is_err(),
"expected invalid content store id {value:?}"
);
}
}
#[test]
fn generated_upload_wal_metadata_segment_and_checkpoint_ids_reject_hyphenated_ids() {
assert!(UploadId::parse("upl_00000000000000000000000000000001").is_ok());
assert!(MetadataSegmentId::parse("seg_00000000000000000000000000000001").is_ok());
assert!(CheckpointId::parse("pin_00000000000000000001-0000000000000001").is_ok());
assert!(UploadId::parse(["upl", "123"].join("-")).is_err());
assert!(MetadataSegmentId::parse(["seg", "123"].join("-")).is_err());
assert!(CheckpointId::parse(["chk", "123"].join("-")).is_err());
}
#[test]
fn generated_runtime_ids_use_lower_hex_bodies() {
let upload_id = UploadId::generate();
let metadata_segment_id = MetadataSegmentId::generate();
let checkpoint_id = CheckpointId::generate(ManifestNo(1));
assert_generated_id_shape(upload_id.as_str(), "upl");
assert_generated_id_shape(metadata_segment_id.as_str(), "seg");
assert_eq!(checkpoint_id.manifest_no(), ManifestNo(1));
assert!(UploadId::parse(upload_id.as_str()).is_ok());
assert!(MetadataSegmentId::parse(metadata_segment_id.as_str()).is_ok());
assert!(CheckpointId::parse(checkpoint_id.as_str()).is_ok());
}
#[test]
fn checkpoint_ids_order_and_validate_their_manifest_numbers() {
let first = CheckpointId::parse("pin_00000000000000000009-ffffffffffffffff").expect("pin");
let second = CheckpointId::parse("pin_00000000000000000010-0000000000000000").expect("pin");
assert!(first < second);
assert_eq!(second.manifest_no(), ManifestNo(10));
let snapshot_id = SnapshotId::from(second.clone());
let decoded: SnapshotId = serde_json::from_str(
&serde_json::to_string(&snapshot_id).expect("serialize snapshot id"),
)
.expect("decode snapshot id");
assert_eq!(CheckpointId::from(decoded), second);
for invalid in [
"pin_00000000000000000000-0000000000000000".to_owned(),
format!("pin_{:020}-0000000000000000", MAX_PUBLIC_INTEGER + 1),
"pin_00000000000000000001-000000000000000G".to_owned(),
"pin_1-0000000000000000".to_owned(),
"pin_00000000000000000001-00000000000000000".to_owned(),
] {
assert!(CheckpointId::parse(&invalid).is_err());
assert!(SnapshotId::parse(&invalid).is_err());
}
}
#[test]
fn generated_content_ids_are_unique_and_shard_uniformly() {
let mut ids = BTreeSet::new();
let mut first_level_shards = BTreeSet::new();
let mut leaf_shards = BTreeSet::new();
for _ in 0..512 {
let id = ContentId::generate();
assert_generated_id_shape(id.as_str(), "con");
let [first, second] = id.shard_prefixes();
assert_eq!(first, &id.as_str()["con_".len().."con_".len() + 2]);
assert_eq!(second, &id.as_str()["con_".len() + 2.."con_".len() + 4]);
first_level_shards.insert(first.to_owned());
leaf_shards.insert(format!("{first}/{second}"));
assert!(
ids.insert(id.clone()),
"generated duplicate content id {id}"
);
}
assert!(
first_level_shards.len() > 128,
"content id first-level shards are not spread: {} distinct",
first_level_shards.len()
);
assert!(
leaf_shards.len() > 480,
"content id leaf shards are not spread: {} distinct",
leaf_shards.len()
);
}
#[test]
fn content_id_parse_requires_the_generated_id_shape() {
assert!(ContentId::parse("con_0123456789abcdef0123456789abcdef").is_ok());
assert!(ContentId::parse("upl_0123456789abcdef0123456789abcdef").is_err());
}
fn assert_generated_id_shape(value: &str, prefix: &str) {
let expected_prefix = format!("{prefix}_");
let body = value
.strip_prefix(&expected_prefix)
.expect("generated id prefix");
assert_eq!(body.len(), 32);
assert!(
body.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
"generated id body must be lowercase hex: {value}"
);
}
#[test]
fn name_key_parse_rejects_invalid_values() {
assert_eq!(
NameKey::parse("").expect_err("empty").reason(),
"must not be empty"
);
assert_eq!(
NameKey::parse("a/b").expect_err("slash").reason(),
"must not contain `/`"
);
assert_eq!(
NameKey::parse(".").expect_err("dot").reason(),
"must not be `.` or `..`"
);
assert_eq!(
NameKey::parse("a\u{0}b").expect_err("control").reason(),
"must not contain control characters"
);
NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES)).expect("cap is inclusive");
assert_eq!(
NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES + 1))
.expect_err("over cap")
.reason(),
"exceeds the maximum name key length of 768 bytes"
);
}
#[test]
fn name_key_serializes_as_string_and_validates_deserialize() {
let name_key = NameKey::parse("report.txt").expect("valid name key");
assert_eq!(
serde_json::to_string(&name_key).expect("serialize name key"),
"\"report.txt\""
);
assert_eq!(
serde_json::from_str::<NameKey>("\"report.txt\"").expect("deserialize name key"),
name_key
);
assert!(serde_json::from_str::<NameKey>("\"a/b\"").is_err());
}
}