From 523f5ab86cb609a0fd63e89734e303dd146aed8f Mon Sep 17 00:00:00 2001 From: Andrey Vasnetsov Date: Wed, 21 Oct 2026 18:38:47 +0200 Subject: [PATCH] Reject . and .. as vector names (#11290) Since #11275 segments refuse to load a vector named `..`, but the API still accepted it. `PUT /collections/{c}/vectors/%2E%2E` stored `..` in the collection config while every shard declined the operation. After that, snapshots and optimizations of the collection failed, and on restart the node panicked loading the shard. Reject both dot segments in `validate_vector_name`, so the API and the segment check agree. Claude-Session: https://claude.ai/code/session_012FyoTtcVeNR7DjV6nuWF3f Co-authored-by: generall Co-authored-by: Claude Opus 5.5 (cherry picked from commit f402454e2cde394aef3f6e551ab121351338e86d) --- lib/common/common/src/validation.rs | 56 +++++++++++++++++++++++-- tests/openapi/test_named_vector_crud.py | 34 +++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) 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),