pub const MAX_ID_LENGTH: usize = 4096;
pub const MAX_LOCAL_COMPONENT_LENGTH: usize = 1024;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::RegistryError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SourceKind {
HuggingFace,
Local,
}
impl fmt::Display for SourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::HuggingFace => "huggingface",
Self::Local => "local",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelId {
pub source: SourceKind,
pub repo: String,
pub path: Option<Vec<String>>,
}
fn parse_local_id(trimmed: &str) -> Result<ModelId, RegistryError> {
let body = trimmed
.strip_prefix('/')
.or_else(|| trimmed.strip_prefix("./"))
.or_else(|| trimmed.strip_prefix("../"))
.unwrap_or(trimmed);
if body.contains('\0') || body.contains('\\') || body.chars().any(|c| c.is_control()) {
return Err(RegistryError::Parse(format!(
"local id must not contain control characters or backslashes: {trimmed:?}"
)));
}
for component in body.split('/') {
if component.is_empty() {
continue;
}
if component == ".." {
return Err(RegistryError::Parse(format!(
"path traversal: .. segment in local id: {trimmed:?}"
)));
}
if component == "." {
return Err(RegistryError::Parse(format!(
"path traversal: . segment in local id: {trimmed:?}"
)));
}
if component.len() > MAX_LOCAL_COMPONENT_LENGTH {
return Err(RegistryError::Parse(format!(
"local id component too long (max {} bytes): {trimmed:?}",
MAX_LOCAL_COMPONENT_LENGTH,
)));
}
}
Ok(ModelId {
source: SourceKind::Local,
repo: trimmed.to_string(),
path: None,
})
}
impl ModelId {
pub fn parse(s: &str) -> Result<Self, RegistryError> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(RegistryError::Parse("empty id".into()));
}
if trimmed.len() > MAX_ID_LENGTH {
return Err(RegistryError::Parse(format!(
"model id too long (max {} bytes): {} bytes",
MAX_ID_LENGTH,
trimmed.len(),
)));
}
if trimmed.starts_with('/') || trimmed.starts_with("./") || trimmed.starts_with("../") {
return parse_local_id(trimmed);
}
let parts: Vec<&str> = trimmed.split('/').collect();
match parts.len() {
2 => {
let org = parts[0];
let repo = parts[1];
if org.is_empty() || repo.is_empty() {
return Err(RegistryError::Parse(format!(
"HF id must have non-empty org and repo: {trimmed:?}"
)));
}
if org.contains(' ') || repo.contains(' ') {
return Err(RegistryError::Parse(format!(
"HF id segments must not contain spaces: {trimmed:?}"
)));
}
Ok(Self {
source: SourceKind::HuggingFace,
repo: format!("{org}/{repo}"),
path: None,
})
}
3 => {
let org = parts[0];
let repo = parts[1];
let file = parts[2];
if org.is_empty() || repo.is_empty() || file.is_empty() {
return Err(RegistryError::Parse(format!(
"HF single-file id must have three non-empty segments: {trimmed:?}"
)));
}
if file.contains(' ') {
return Err(RegistryError::Parse(format!(
"HF single-file file segment must not contain spaces: {trimmed:?}"
)));
}
if file == "."
|| file == ".."
|| file.contains('/')
|| file.contains('\\')
|| file.contains('\0')
{
return Err(RegistryError::Parse(format!(
"HF single-file file segment must be a bare filename \
(no traversal, no separators, no NULs): {trimmed:?}"
)));
}
Ok(Self {
source: SourceKind::HuggingFace,
repo: format!("{org}/{repo}"),
path: Some(vec![file.to_string()]),
})
}
n => Err(RegistryError::Parse(format!(
"HF id must have 2 or 3 segments, got {n}: {trimmed:?}"
))),
}
}
pub fn canonical(&self) -> String {
match (&self.source, &self.path) {
(SourceKind::HuggingFace, None) => self.repo.clone(),
(SourceKind::HuggingFace, Some(p)) if !p.is_empty() => {
format!("{}/{}", self.repo, p.join("/"))
}
(SourceKind::Local, _) => self.repo.clone(),
(SourceKind::HuggingFace, Some(p)) => {
debug_assert!(!p.is_empty(), "ModelId::path should not be empty");
self.repo.clone()
}
}
}
}
impl fmt::Display for ModelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.canonical())
}
}
impl FromStr for ModelId {
type Err = RegistryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_two_segment_hf() {
let id = ModelId::parse("Qwen/Qwen3-ASR-0.6B").unwrap();
assert_eq!(id.source, SourceKind::HuggingFace);
assert_eq!(id.repo, "Qwen/Qwen3-ASR-0.6B");
assert!(id.path.is_none());
}
#[test]
fn parse_three_segment_hf() {
let id = ModelId::parse("ggerganov/whisper.cpp/ggml-large-v3.bin").unwrap();
assert_eq!(id.source, SourceKind::HuggingFace);
assert_eq!(id.repo, "ggerganov/whisper.cpp");
assert_eq!(id.path, Some(vec!["ggml-large-v3.bin".to_string()]));
}
#[test]
fn parse_local_absolute() {
let id = ModelId::parse("/cache/models/qwen").unwrap();
assert_eq!(id.source, SourceKind::Local);
assert_eq!(id.repo, "/cache/models/qwen");
}
#[test]
fn parse_local_relative() {
let id = ModelId::parse("./local-model").unwrap();
assert_eq!(id.source, SourceKind::Local);
}
#[test]
fn parse_rejects_too_many_segments() {
assert!(ModelId::parse("a/b/c/d").is_err());
}
#[test]
fn parse_rejects_empty_segments() {
assert!(ModelId::parse("org//file").is_err());
}
#[test]
fn parse_rejects_empty_input() {
assert!(ModelId::parse("").is_err());
assert!(ModelId::parse(" ").is_err());
}
#[test]
fn canonical_round_trips() {
for s in [
"Qwen/Qwen3-ASR-0.6B",
"ggerganov/whisper.cpp/ggml-large-v3.bin",
"/cache/models/qwen",
"./local-model",
] {
let id = ModelId::parse(s).unwrap();
assert_eq!(id.canonical(), s, "round-trip for {s:?}");
}
}
#[test]
fn parse_rejects_traversal_segment() {
for bad in [
"foo/bar/..",
"foo/bar/.",
"foo/bar/foo\\bar",
"foo/bar/with\0null",
] {
let err = ModelId::parse(bad).expect_err(&format!(
"traversal/separator segment must be rejected: {bad:?}"
));
match err {
RegistryError::Parse(_) => {}
other => panic!("expected Parse, got {other:?}"),
}
}
}
#[test]
fn parse_accepts_dotted_filename() {
let id = ModelId::parse("foo/bar/.hidden").expect("dotfile accepted");
assert_eq!(id.repo, "foo/bar");
assert_eq!(id.path, Some(vec![".hidden".to_string()]));
}
#[test]
fn parse_local_rejects_traversal_subpath() {
for bad in [
"/safe/../etc/passwd",
"/srv/models/../../etc/passwd",
"./foo/../bar",
"/../etc/passwd",
"./../escape",
] {
let err = ModelId::parse(bad)
.expect_err(&format!("traversal subpath must be rejected: {bad:?}"));
match err {
RegistryError::Parse(msg) => {
assert!(
msg.contains("traversal") || msg.contains(".."),
"expected traversal wording, got {msg:?}",
);
}
other => panic!("expected Parse, got {other:?}"),
}
}
}
#[test]
fn parse_local_rejects_control_chars() {
for bad in [
"/safe/\x00evil",
"/safe/with\0null",
"/safe/with\\backslash",
] {
let err = ModelId::parse(bad).expect_err(&format!(
"control char / backslash must be rejected: {bad:?}"
));
assert!(
matches!(err, RegistryError::Parse(_)),
"expected Parse, got {err:?}",
);
}
}
#[test]
fn parse_local_rejects_too_long_id() {
let bad = "a".repeat(MAX_ID_LENGTH + 1);
let err = ModelId::parse(&bad).expect_err("oversized id must be rejected");
match err {
RegistryError::Parse(msg) => {
assert!(
msg.contains("too long"),
"expected 'too long' wording, got {msg:?}",
);
}
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn parse_local_accepts_leading_traversal_prefix_only() {
}
#[test]
fn parse_local_accepts_absolute_safe_path() {
let id = ModelId::parse("/srv/models/whisper/ggml-tiny.bin")
.expect("absolute safe path accepted");
assert_eq!(id.source, SourceKind::Local);
assert_eq!(id.repo, "/srv/models/whisper/ggml-tiny.bin");
}
}