diff --git a/lib/common/common/src/validation.rs b/lib/common/common/src/validation.rs index 250f82e2e7..8d83739464 100644 --- a/lib/common/common/src/validation.rs +++ b/lib/common/common/src/validation.rs @@ -160,8 +160,12 @@ pub fn validate_collection_name(value: &str) -> Result<(), ValidationError> { /// /// Vector names become directory components on disk (see /// `segment_constructor::get_vector_storage_path`), so they are subject to the same -/// rules as collection names: at most 200 bytes, and free of the -/// filesystem-unsafe characters listed in [`INVALID_NAME_CHARS`]. +/// rules as collection names: at most 200 bytes, free of the +/// filesystem-unsafe characters listed in [`INVALID_NAME_CHARS`], and not a dot +/// segment (`.`, `..`). Segments refuse to load a vector named `..` (see +/// `segment_constructor::check_vector_name_path`), so the API must not create one. +/// +/// Unlike a collection name, a vector name may be empty: that is the unnamed vector. pub fn validate_vector_name(value: &str) -> Result<(), ValidationError> { const MAX_LEN: usize = 200; @@ -174,7 +178,11 @@ pub fn validate_vector_name(value: &str) -> Result<(), ValidationError> { return Err(err); } - check_invalid_name_chars(value, "vector name") + check_invalid_name_chars(value, "vector name")?; + if value.is_empty() { + return Ok(()); + } + check_plain_dir_name(value, "vector name") } /// Validate the collection name contains no illegal characters, legacy edition @@ -525,6 +533,48 @@ mod tests { } } + /// The API must reject every vector name that segment loading rejects + /// (`segment_constructor::check_vector_name_path`). Otherwise the name is stored in the + /// collection config while the segments refuse it, and the shard fails to load. + #[test] + fn test_validate_vector_name() { + let too_long = "v".repeat(201); + for name in [ + "..", + ".", + "x/../../escaped", + "../escaped", + "a/b", + "/abs", + "a\\b", + "a\0b", + too_long.as_str(), + ] { + assert!( + validate_vector_name(name).is_err(), + "vector name {name:?} must be rejected", + ); + } + + // The empty name is the unnamed vector. Names merely containing dots are plain + // directory names. + let longest = "v".repeat(200); + for name in [ + "", + "image", + "v1.2", + ".hidden", + "..dots", + "...", + longest.as_str(), + ] { + assert!( + validate_vector_name(name).is_ok(), + "vector name {name:?} must stay valid", + ); + } + } + #[test] fn test_validate_geo_polygon() { let bad_polygon: Vec<(f64, f64)> = vec![]; diff --git a/tests/openapi/test_named_vector_crud.py b/tests/openapi/test_named_vector_crud.py index 151192cd4b..fd90874ba6 100644 --- a/tests/openapi/test_named_vector_crud.py +++ b/tests/openapi/test_named_vector_crud.py @@ -1,6 +1,9 @@ +import http.client +import json import pytest import time import requests +from urllib.parse import urlparse from .helpers.helpers import qdrant_host_headers, request_with_validation from .helpers.settings import QDRANT_HOST @@ -136,6 +139,37 @@ def test_create_vector_rejects_oversize(collection_name): assert "size" in response.text +@pytest.mark.parametrize("encoded_name", ["%2E", "%2E%2E"]) +def test_create_vector_rejects_dot_segment_name(collection_name, encoded_name): + """`.` and `..` must be rejected at the API boundary. + + Segments refuse to load a vector named `..`. Accepting it stored the name in the collection + config while every shard declined the operation, and the node then failed to load the shard + on restart. + """ + # `requests` unescapes `%2E` and drops dot segments, so send the path as is. + parsed = urlparse(QDRANT_HOST) + conn = http.client.HTTPConnection(parsed.hostname, parsed.port) + conn.request( + "PUT", + f"/collections/{collection_name}/vectors/{encoded_name}?wait=true", + body=json.dumps({"dense": {"size": VECTOR_SIZE1, "distance": "Cosine"}}), + headers={**qdrant_host_headers(), "Content-Type": "application/json"}, + ) + response = conn.getresponse() + text = response.read().decode() + assert response.status == 422, text + + response = request_with_validation( + api='/collections/{collection_name}', + method="GET", + path_params={'collection_name': collection_name}, + ) + assert response.ok + vectors = response.json()["result"]["config"]["params"]["vectors"] + assert "." not in vectors and ".." not in vectors, vectors + + def test_delete_recreate_indexed_vector_scroll(): """ Delete and recreate a named vector on indexed segments (HNSW built),