Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
predicate paths, AWS and GCP resource names, Snowflake account segments, Databricks git refs,
and the identifier and version checks of ODCS and ODPS imports.

- **Schema validation let a value with a trailing newline through a `pattern`.** JSON Schema
writes `pattern` in ECMA-262, where `$` matches only at the end of the value, but the
jsonschema library runs it with Python's `re.search`, whose `$` also matches before a final
newline. So `fluid validate` accepted `id: "orders\n"`, which an `id: |` block scalar produces,
under every bundled schema version (0.7.1 to 0.7.7). Contract, fragment and bundle validation
now read `$` as the end of the value. Such a value is refused at its path, with a hint when
the line break is the only problem: `(it ends in a line break, which a YAML | or > block keeps
and |- or >- drops)`. A `sink.catalogAuth` field gets no hint, since its value is never shown.
The schemas themselves are unchanged.

## [0.26.0] — 2026-11-01

### Added
Expand Down
15 changes: 9 additions & 6 deletions fluid_build/forge/core/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,14 @@ def validate_json_schema(
)
]
schema = schema_manager.get_schema(version)
try:
import jsonschema # type: ignore
except ImportError: # pragma: no cover — jsonschema is a hard dep upstream
from fluid_build.schema_manager import (
JSONSCHEMA_AVAILABLE,
format_schema_error,
schema_validator,
)

# The flag schema_validator needs: a package that is found can still fail to import.
if not JSONSCHEMA_AVAILABLE:
return [
ValidationIssue(
file=path,
Expand All @@ -405,9 +410,7 @@ def validate_json_schema(

# Honour the schema's declared $schema; Draft 7 silently ignores
# newer keywords, which drops the constraints they express.
from fluid_build.schema_manager import format_schema_error

validator = jsonschema.validators.validator_for(schema)(schema)
validator = schema_validator(schema)
errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path))
for err in errors:
loc = "/".join(str(p) for p in err.absolute_path) or "<root>"
Expand Down
72 changes: 70 additions & 2 deletions fluid_build/schema_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@

from __future__ import annotations

import functools
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Union
from typing import Any, Callable, Dict, FrozenSet, Iterator, List, Optional, Union

import yaml

Expand Down Expand Up @@ -110,6 +111,7 @@ def _locate_error(error: Any, locate: Callable[..., Optional[str]]) -> Optional[
known = schema.get("properties") or {}
patterns = list((schema.get("patternProperties") or {}).keys())
for key in error.instance:
# re.search, as jsonschema's additionalProperties reads these keys.
if key not in known and not any(re.search(p, str(key)) for p in patterns):
return locate([*path, key])
if error.validator == "required":
Expand All @@ -132,6 +134,72 @@ def _error_path(error: Any, locate: Optional[Callable[..., Optional[str]]] = Non
return f"{where} ({path})" if where else path


def _end_anchored(pattern: str) -> str:
r"""``pattern`` with each ``$`` anchor written ``\Z``, the end of the value.

JSON Schema writes ``pattern`` in ECMA-262, where ``$`` matches only at the
end of the value. Python's ``$`` also matches before a final ``\n``, so
jsonschema's ``re.search`` let ``"orders\n"`` through ``^[a-z_]+$``.
fastjsonschema and js-regex make the same rewrite with a ``(?<!\\)\$``
finder, which turns ``[$]`` into ``[\Z]`` (that does not compile) and
misses the ``$`` after an escaped backslash (``\\$``). This scan skips
escapes and ``[...]`` sets instead.
"""
out: List[str] = []
i, in_set = 0, False
while i < len(pattern):
token = pattern[i]
if token == "\\":
token = pattern[i : i + 2]
elif in_set:
in_set = token != "]"
elif token == "[":
in_set = True
# A "]" first in a set, after an optional "^", is a literal.
first = i + 1 + (pattern[i + 1 : i + 2] == "^")
if pattern[first : first + 1] == "]":
token = pattern[i : first + 1]
i += len(token)
out.append(r"\Z" if token == "$" and not in_set else token)
return "".join(out)


@functools.lru_cache(maxsize=None)
def _compiled_pattern(pattern: str) -> re.Pattern[str]:
return re.compile(_end_anchored(pattern))


def _pattern_keyword(validator: Any, pattern: str, instance: Any, schema: Any) -> Iterator[Any]:
"""jsonschema's ``pattern`` keyword, with ``$`` read as ECMA-262 reads it."""
if not validator.is_type(instance, "string") or _compiled_pattern(pattern).search(instance):
return
message = f"{instance!r} does not match {pattern!r}"
if re.search(pattern, instance): # only the final line break fails
message += " (it ends in a line break, which a YAML | or > block keeps and |- or >- drops)"
yield jsonschema.ValidationError(message)


@functools.lru_cache(maxsize=None)
def _end_anchored_validator(validator_class: Any) -> Any:
return jsonschema.validators.extend(validator_class, {"pattern": _pattern_keyword})


def schema_validator(schema: Dict[str, Any]) -> Any:
"""A jsonschema validator for ``schema``, in the dialect it declares, whose
``pattern`` ends at the end of the value (:func:`_end_anchored`). Validate
against a FLUID schema through this, never ``validator_for`` directly.

The keyword is replaced the way check-jsonschema replaces it, with
``jsonschema.validators.extend``. Two places keep jsonschema's own
``re.search``: ``patternProperties`` keys, as its ``additionalProperties``
and :func:`_locate_error` read them, and a subschema that declares its own
``$schema`` (or a ``$ref`` to the root, which does), for which jsonschema
picks that dialect's own class. No FLUID schema has either; a test fails if
one does.
"""
return _end_anchored_validator(jsonschema.validators.validator_for(schema))(schema)


def _schema_version_sort_key(version: str) -> tuple[int, int, int]:
match = VERSION_PATTERN.match(version)
if not match:
Expand Down Expand Up @@ -736,7 +804,7 @@ def _validate_with_jsonschema(
# 2020-12; validating them with Draft 7 worked only because none
# has yet used a 2020-12-only keyword. The first one to do so
# would have been accepted without error.
validator = jsonschema.validators.validator_for(schema)(schema)
validator = schema_validator(schema)

# Validate and collect errors
errors = sorted(validator.iter_errors(contract), key=lambda e: e.path)
Expand Down
6 changes: 5 additions & 1 deletion tests/test_catalog_auth_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ def test_ga_075_refuses_the_key():
({**_OAUTH2, "credentialEnv": "LK_CRED\n"}, "credentialEnv: does not match"),
({"type": "bearer", "tokenEnv": "UC_TOKEN\n"}, "tokenEnv: does not match"),
({"type": "bearer", "tokenEnv": _PAT}, "tokenEnv: must not match"),
# The token check's "$" no longer matches before the "\n"; the name pattern refuses it.
({"type": "bearer", "tokenEnv": _PAT + "\n"}, "tokenEnv: does not match"),
(
{**_OAUTH2, "credentialEnv": "AKIAIOSFODNN7EXAMPLE"}, # pragma: allowlist secret
"credentialEnv: must not match",
Expand Down Expand Up @@ -193,9 +195,10 @@ def test_076_refuses_a_malformed_catalog_auth(catalog_auth, expected):
({"type": "bearer", "tokenEnv": "T", "credentialEnv": _SECRET}, _SECRET),
({"type": _SECRET}, _SECRET),
({"type": "bearer", "tokenEnv": _PAT}, _PAT),
({"type": "bearer", "tokenEnv": _PAT + "\n"}, _PAT),
({**_OAUTH2, "oauth2ServerUri": _USERINFO_URI}, "2OR3eRvYfSZ"),
],
ids=["credentialEnv", "tokenEnv", "wrong-type-field", "type", "pat", "userinfo"],
ids=["credentialEnv", "tokenEnv", "wrong-type-field", "type", "pat", "pat-lf", "userinfo"],
)
def test_a_schema_error_never_quotes_the_value(catalog_auth, secret):
errors = _errors("0.7.6", catalog_auth)
Expand Down Expand Up @@ -224,6 +227,7 @@ def test_schema_and_parser_share_the_variable_name_rule():
{**_OAUTH2, "credentialEnv": "LK_CRED\n"},
{"type": "bearer", "tokenEnv": "UC_TOKEN\n"},
{"type": "bearer", "tokenEnv": _PAT},
{"type": "bearer", "tokenEnv": _PAT + "\n"},
{"type": "bearer", "tokenEnv": "S3_CREDENTIAL_V2"},
{"type": "bearer", "tokenEnv": "LAKEKEEPERCATALOGCREDENTIALPROD"},
{**_OAUTH2, "oauth2ServerUri": _USERINFO_URI},
Expand Down
Loading
Loading