From cb555e4c05856f47b41f32b1684bf94fe3d75f6e Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:11:56 +0200 Subject: [PATCH 1/2] Validate Component build steps, build tools and artifact includes Adds spec.buildTools, spec.build and spec.artifact to the Component schema and SDK model (S-002), and makes config validate refuse a step whose command[0] is not a declared build tool, absolute or climbing paths, and buildTools or artifact without build. Running the steps is issue 115. Fixes #113 Co-Authored-By: Claude Opus 5.5 --- changelog.d/113-component-build-validation.md | 3 + config/schemas/v1/component.schema.json | 75 ++++++ sdk/src/cloudfall/domain.py | 123 ++++++++++ sdk/src/cloudfall/inventory.py | 115 ++++++++++ sdk/src/cloudfall/validation.py | 42 ++++ sdk/tests/test_component_build_validation.py | 215 ++++++++++++++++++ 6 files changed, 573 insertions(+) create mode 100644 changelog.d/113-component-build-validation.md create mode 100644 sdk/tests/test_component_build_validation.py diff --git a/changelog.d/113-component-build-validation.md b/changelog.d/113-component-build-validation.md new file mode 100644 index 0000000..a5c3cf3 --- /dev/null +++ b/changelog.d/113-component-build-validation.md @@ -0,0 +1,3 @@ +### Added + +- A Component can declare `spec.buildTools` (tool name to minimum version), `spec.build` (ordered steps, each a `workingDirectory` inside the packaged root, an argv `command` and an optional `timeoutSeconds` from 1 to 3600, default 900) and `spec.artifact.include` (relative paths the build produces); `cloudfall config validate` refuses a step whose `command[0]` is not in `buildTools` (`component_build_tool_undeclared`), an absolute or `..` path, and `buildTools` or `artifact` without `build`. Validation only: `artifact build` does not run the steps yet. Components without `build` validate as before (#113) diff --git a/config/schemas/v1/component.schema.json b/config/schemas/v1/component.schema.json index b2ff0d1..215e9a2 100644 --- a/config/schemas/v1/component.schema.json +++ b/config/schemas/v1/component.schema.json @@ -211,6 +211,66 @@ "$ref": "common.schema.json#/$defs/secretReference" }, "uniqueItems": true + }, + "buildTools": { + "type": "object", + "minProperties": 1, + "patternProperties": { + "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$": { + "type": "string", + "pattern": "^[0-9]{1,9}(?:\\.[0-9]{1,9}){0,7}$" + } + }, + "additionalProperties": false + }, + "build": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "workingDirectory": { + "$ref": "#/$defs/relativePath" + }, + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\u0000]+$" + }, + "minItems": 1 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600, + "default": 900 + } + }, + "required": [ + "workingDirectory", + "command" + ], + "additionalProperties": false + } + }, + "artifact": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "$ref": "#/$defs/relativePath" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "include" + ], + "additionalProperties": false } }, "required": [ @@ -221,9 +281,24 @@ "service", "healthCheck" ], + "dependentRequired": { + "buildTools": [ + "build" + ], + "artifact": [ + "build" + ] + }, "additionalProperties": false } }, + "$defs": { + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + } + }, "required": [ "apiVersion", "kind", diff --git a/sdk/src/cloudfall/domain.py b/sdk/src/cloudfall/domain.py index 59f0b8c..1d41000 100644 --- a/sdk/src/cloudfall/domain.py +++ b/sdk/src/cloudfall/domain.py @@ -24,6 +24,11 @@ _PACKAGE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9+.-]{0,199}$") _SERVICE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.service$") _SYSTEMD_UNIT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.(?:service|timer)$") +_BUILD_TOOL_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,63}") +_BUILD_TOOL_VERSION_PATTERN = re.compile(r"[0-9]{1,9}(?:\.[0-9]{1,9}){0,7}") +_CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]") +BUILD_STEP_DEFAULT_TIMEOUT_SECONDS = 900 +BUILD_STEP_MAXIMUM_TIMEOUT_SECONDS = 3600 _FILE_MODE_PATTERN = re.compile(r"^0[0-7]{3}$") _SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$") _MAX_HOSTNAME_LENGTH = 253 @@ -675,6 +680,124 @@ def from_boundary(cls, value: object) -> ServiceCommand: return cls(tuple(value)) +@dataclass(frozen=True, slots=True) +class RelativeRepositoryPath: + """Path inside a component's packaged root: relative, never climbing out.""" + + value: str + + def __post_init__(self) -> None: + """Reject absolute paths, ``..`` segments, and control characters.""" + if ( + not self.value + or _CONTROL_CHARACTER_PATTERN.search(self.value) + or self.value.startswith("/") + or ".." in self.value.split("/") + ): + message = f"invalid relative repository path: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> RelativeRepositoryPath: + """Coerce a boundary value into a relative repository path.""" + return cls(_required_string(value, "relative repository path")) + + +@dataclass(frozen=True, slots=True) +class BuildToolName: + """Executable a component build step runs, looked up on ``PATH``.""" + + value: str + + def __post_init__(self) -> None: + """Accept a bare executable name only, never a path.""" + if not _BUILD_TOOL_NAME_PATTERN.fullmatch(self.value): + message = f"invalid build tool name: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> BuildToolName: + """Coerce a boundary value into a build tool name.""" + return cls(_required_string(value, "build tool name")) + + +@dataclass(frozen=True, slots=True) +class BuildToolVersion: + """Minimum dotted numeric version of a build tool.""" + + value: str + + def __post_init__(self) -> None: + """Accept one to eight dotted numeric parts.""" + if not _BUILD_TOOL_VERSION_PATTERN.fullmatch(self.value): + message = f"invalid build tool version: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> BuildToolVersion: + """Coerce a boundary value into a build tool version.""" + return cls(_required_string(value, "build tool version")) + + +@dataclass(frozen=True, slots=True) +class BuildCommand: + """Argv list of one component build step, never a shell string.""" + + value: tuple[str, ...] + + def __post_init__(self) -> None: + """Reject empty argv lists, empty arguments, and NUL bytes.""" + if not self.value: + message = "build command must not be empty" + raise ValueError(message) + for argument in self.value: + if not argument or "\x00" in argument: + message = f"invalid build command argument: {argument!r}" + raise ValueError(message) + + @property + def executable(self) -> BuildToolName: + """The tool the step runs: ``command[0]``.""" + return BuildToolName(self.value[0]) + + @classmethod + def from_boundary(cls, value: object) -> BuildCommand: + """Coerce a boundary argv list into a build command.""" + if not isinstance(value, list) or not all( + isinstance(item, str) for item in value + ): + message = "build command must be a list of strings" + raise TypeError(message) + return cls(tuple(value)) + + +@dataclass(frozen=True, slots=True) +class BuildTimeoutSeconds: + """Time limit of one build step: 1 to 3600 seconds, 900 by default.""" + + value: int = BUILD_STEP_DEFAULT_TIMEOUT_SECONDS + + def __post_init__(self) -> None: + """Reject booleans and values outside 1..3600.""" + if ( + isinstance(self.value, bool) + or not isinstance(self.value, int) + or not 1 <= self.value <= BUILD_STEP_MAXIMUM_TIMEOUT_SECONDS + ): + message = f"invalid build step timeout: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> BuildTimeoutSeconds: + """Coerce a boundary value, defaulting when it is absent.""" + if value is None: + return cls() + if not isinstance(value, int) or isinstance(value, bool): + message = "build step timeout must be an integer" + raise TypeError(message) + return cls(value) + + @dataclass(frozen=True, slots=True) class PostgresDatabaseName: """Validated PostgreSQL database name safe for quoted identifiers.""" diff --git a/sdk/src/cloudfall/inventory.py b/sdk/src/cloudfall/inventory.py index ad6a354..8d0f879 100644 --- a/sdk/src/cloudfall/inventory.py +++ b/sdk/src/cloudfall/inventory.py @@ -10,6 +10,10 @@ AlertDuration, AlertSeverity, AlertSummary, + BuildCommand, + BuildTimeoutSeconds, + BuildToolName, + BuildToolVersion, ByteSize, ConfigCapture, ConnectionAddress, @@ -43,6 +47,7 @@ PromqlExpression, ProviderServerId, RaidLevel, + RelativeRepositoryPath, RepositoryUrl, RequiredServiceState, RequiredServiceStatus, @@ -480,6 +485,73 @@ def as_dict(self) -> dict[str, object]: return result +@dataclass(frozen=True, slots=True) +class ComponentBuildTool: + """One tool a build step runs and its minimum version.""" + + name: BuildToolName + minimum_version: BuildToolVersion + + +@dataclass(frozen=True, slots=True) +class ComponentBuildStep: + """One build step: an argv list run in a directory of the packaged root.""" + + working_directory: RelativeRepositoryPath + command: BuildCommand + timeout_seconds: BuildTimeoutSeconds + + def as_dict(self) -> dict[str, object]: + """Serialize the step contract.""" + return { + "workingDirectory": self.working_directory.value, + "command": list(self.command.value), + "timeoutSeconds": self.timeout_seconds.value, + } + + +@dataclass(frozen=True, slots=True) +class ComponentBuild: + """Controller-side build steps that run before a component is packaged.""" + + tools: tuple[ComponentBuildTool, ...] + steps: tuple[ComponentBuildStep, ...] + include: tuple[RelativeRepositoryPath, ...] = () + + def __post_init__(self) -> None: + """Require steps, unique tools and includes, and declared executables.""" + if not self.steps: + message = "component build requires at least one step" + raise ValueError(message) + tool_names = [tool.name for tool in self.tools] + if len(set(tool_names)) != len(tool_names): + message = "component build tools must be unique" + raise ValueError(message) + if len(set(self.include)) != len(self.include): + message = "component artifact include paths must be unique" + raise ValueError(message) + declared = frozenset(tool_names) + for index, step in enumerate(self.steps): + if step.command.executable not in declared: + message = ( + f"build step {index} runs {step.command.executable.value!r}, " + "which spec.buildTools does not declare" + ) + raise ValueError(message) + + def as_dict(self) -> dict[str, object]: + """Serialize the build contract in the Component spec's shape.""" + result: dict[str, object] = { + "buildTools": { + tool.name.value: tool.minimum_version.value for tool in self.tools + }, + "build": [step.as_dict() for step in self.steps], + } + if self.include: + result["artifact"] = {"include": [path.value for path in self.include]} + return result + + @dataclass(frozen=True, slots=True) class ComponentInventory: """Placement and deployment contract for one deployable component.""" @@ -495,6 +567,7 @@ class ComponentInventory: health_check: ComponentHealthCheck secret_refs: tuple[SecretReference, ...] = () environment: tuple[tuple[str, str], ...] = () + build: ComponentBuild | None = None def as_dict(self) -> dict[str, object]: """Serialize the component for inventory consumers.""" @@ -511,6 +584,8 @@ def as_dict(self) -> dict[str, object]: } if self.environment: result["environment"] = dict(self.environment) + if self.build is not None: + result.update(self.build.as_dict()) return result @@ -1470,6 +1545,46 @@ def _component_inventory(document: ResourceDocument) -> ComponentInventory: health_check=_component_health_check(health), secret_refs=_secret_references(spec.get("secretRefs")), environment=_declared_environment(spec.get("environment")), + build=_component_build(spec), + ) + + +def _component_build(spec: Mapping[str, object]) -> ComponentBuild | None: + if spec.get("build") is None: + if spec.get("buildTools") is not None or spec.get("artifact") is not None: + message = "validated component has buildTools or artifact without build" + raise ValueError(message) + return None + raw_tools = spec.get("buildTools") + tools = () if raw_tools is None else tuple( + ComponentBuildTool( + name=BuildToolName.from_boundary(name), + minimum_version=BuildToolVersion.from_boundary(version), + ) + for name, version in _boundary_mapping(raw_tools, "build tools").items() + ) + raw_artifact = spec.get("artifact") + include = () if raw_artifact is None else tuple( + RelativeRepositoryPath.from_boundary(item) + for item in _list( + _mapping(spec, "artifact").get("include"), "artifact include paths" + ) + ) + return ComponentBuild( + tools=tools, + steps=tuple( + ComponentBuildStep( + working_directory=RelativeRepositoryPath.from_boundary( + step.get("workingDirectory") + ), + command=BuildCommand.from_boundary(step.get("command")), + timeout_seconds=BuildTimeoutSeconds.from_boundary( + step.get("timeoutSeconds") + ), + ) + for step in _mapping_list(spec.get("build"), "build steps") + ), + include=include, ) diff --git a/sdk/src/cloudfall/validation.py b/sdk/src/cloudfall/validation.py index ef5d1d2..4761c3c 100644 --- a/sdk/src/cloudfall/validation.py +++ b/sdk/src/cloudfall/validation.py @@ -515,6 +515,7 @@ def _validate_component_references( ) deployment = _required_mapping(spec, "deployment", document.source) _validate_install_root(deployment, application_id, document.source) + _validate_component_build(spec, document.source) server_ids = _resource_id_list(deployment, "servers", document.source) for server_id in server_ids: _require_resource( @@ -1276,6 +1277,47 @@ def _validate_install_root( raise ConfigValidationError(issue) +def _validate_component_build( + spec: Mapping[str, object], source: SourceLocation +) -> None: + raw_steps = spec.get("build") + if raw_steps is None: + return + raw_tools = spec.get("buildTools") + tools = {} if raw_tools is None else _required_mapping(spec, "buildTools", source) + if not isinstance(raw_steps, list): + issue = ValidationIssue( + code="internal_state_shape_invalid", + message="validated build is not a list", + source=source, + field_path=("spec", "build"), + ) + raise ConfigValidationError(issue) + for index, step in enumerate(raw_steps): + command = step.get("command") if isinstance(step, dict) else None + if not isinstance(command, list) or not command: + issue = ValidationIssue( + code="internal_state_shape_invalid", + message="validated build step command is not a non-empty list", + source=source, + field_path=("spec", "build", index, "command"), + ) + raise ConfigValidationError(issue) + executable = command[0] + if executable not in tools: + issue = ValidationIssue( + code="component_build_tool_undeclared", + message=( + f"build step {index} runs {executable!r}, which " + "spec.buildTools does not declare; add it with its " + "minimum version" + ), + source=source, + field_path=("spec", "build", index, "command", 0), + ) + raise ConfigValidationError(issue) + + def _validate_alert_rule_references( document: ResourceDocument, spec: Mapping[str, object], diff --git a/sdk/tests/test_component_build_validation.py b/sdk/tests/test_component_build_validation.py new file mode 100644 index 0000000..bf15661 --- /dev/null +++ b/sdk/tests/test_component_build_validation.py @@ -0,0 +1,215 @@ +"""Validation of a Component's build steps, build tools and artifact includes.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from cloudfall.cli import main +from cloudfall.domain import ResourceId, ResourceKind +from cloudfall.inventory import PlatformInventory +from cloudfall.validation import ConfigValidationError, validate_config + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" + +_BUILD_BLOCK = """\ + buildTools: + node: "22.0.0" + pnpm: "9.0.0" + build: + - workingDirectory: web + command: [pnpm, install, --frozen-lockfile] + - workingDirectory: web + command: [pnpm, build] + artifact: + include: [web/dist] +""" + + +def _project_with_component_spec(tmp_path: Path, extra: str) -> Path: + project_directory = tmp_path / "config" + shutil.copytree(EXAMPLES, project_directory) + component_path = project_directory / "components" / "crm-backend.yaml" + component_path.write_text( + component_path.read_text(encoding="utf-8") + extra, encoding="utf-8" + ) + return project_directory + + +def test_s002_1_component_with_build_steps_validates(tmp_path: Path) -> None: + project_directory = _project_with_component_spec(tmp_path, _BUILD_BLOCK) + + state = validate_config(project_directory, SCHEMAS) + + component = next( + item + for item in PlatformInventory.from_state(state).components + if item.resource_id == ResourceId("crm-backend") + ) + assert component.build is not None + assert component.as_dict()["buildTools"] == {"node": "22.0.0", "pnpm": "9.0.0"} + assert component.as_dict()["build"] == [ + { + "workingDirectory": "web", + "command": ["pnpm", "install", "--frozen-lockfile"], + "timeoutSeconds": 900, + }, + { + "workingDirectory": "web", + "command": ["pnpm", "build"], + "timeoutSeconds": 900, + }, + ] + assert component.as_dict()["artifact"] == {"include": ["web/dist"]} + + +def test_s002_1_existing_component_without_build_is_unchanged() -> None: + state = validate_config(EXAMPLES, SCHEMAS) + + assert state.get(ResourceKind.COMPONENT, ResourceId("crm-backend")) + for component in PlatformInventory.from_state(state).components: + assert component.build is None + serialized = component.as_dict() + assert {"build", "buildTools", "artifact"}.isdisjoint(serialized) + + +def test_s002_1_build_step_timeout_accepts_its_bounds(tmp_path: Path) -> None: + project_directory = _project_with_component_spec( + tmp_path, + _BUILD_BLOCK.replace( + "command: [pnpm, build]", + "command: [pnpm, build]\n timeoutSeconds: 3600", + ), + ) + + state = validate_config(project_directory, SCHEMAS) + + component = PlatformInventory.from_state(state).components[0] + assert component.build is not None + assert component.build.steps[1].timeout_seconds.value == 3600 + + +def test_s002_11_step_running_an_undeclared_tool_is_refused( + tmp_path: Path, +) -> None: + project_directory = _project_with_component_spec( + tmp_path, _BUILD_BLOCK.replace("command: [pnpm, build]", "command: [npx, vite]") + ) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "component_build_tool_undeclared" + assert "'npx'" in error.value.issue.message + assert error.value.issue.field_path == ("spec", "build", 1, "command", 0) + + +def test_s002_11_cli_refuses_an_undeclared_tool_as_config_invalid( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project_directory = _project_with_component_spec( + tmp_path, _BUILD_BLOCK.replace("command: [pnpm, build]", "command: [npx, vite]") + ) + + exit_code = main( + ["config", "validate", "--project", str(project_directory), "--schemas", + str(SCHEMAS)] + ) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 80 + assert payload["error"]["code"] == "CONFIG_INVALID" + assert payload["error"]["context"]["code"] == "component_build_tool_undeclared" + + +def test_s002_11_build_without_build_tools_is_refused(tmp_path: Path) -> None: + tools = ' buildTools:\n node: "22.0.0"\n pnpm: "9.0.0"\n' + block = _BUILD_BLOCK.replace(tools, "") + project_directory = _project_with_component_spec(tmp_path, block) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "component_build_tool_undeclared" + + +@pytest.mark.parametrize( + ("original", "replacement"), + [ + ("workingDirectory: web\n command: [pnpm, build]", + "workingDirectory: /web\n command: [pnpm, build]"), + ("workingDirectory: web\n command: [pnpm, build]", + "workingDirectory: web/../..\n command: [pnpm, build]"), + ("workingDirectory: web\n command: [pnpm, build]", + "workingDirectory: ..\n command: [pnpm, build]"), + ("include: [web/dist]", "include: [/srv/web/dist]"), + ("include: [web/dist]", "include: [web/../../etc]"), + ], + ids=[ + "absolute-working-directory", + "climbing-working-directory", + "parent-working-directory", + "absolute-include", + "climbing-include", + ], +) +def test_s002_11_absolute_or_climbing_paths_are_refused( + tmp_path: Path, original: str, replacement: str +) -> None: + project_directory = _project_with_component_spec( + tmp_path, _BUILD_BLOCK.replace(original, replacement) + ) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "schema_validation_failed" + assert error.value.issue.field_path[:2] in {("spec", "build"), ("spec", "artifact")} + + +@pytest.mark.parametrize( + ("extra", "dependent"), + [ + (' buildTools:\n node: "22.0.0"\n', "buildTools"), + (" artifact:\n include: [web/dist]\n", "artifact"), + ], + ids=["build-tools-alone", "artifact-alone"], +) +def test_s002_11_build_tools_or_artifact_without_build_is_refused( + tmp_path: Path, extra: str, dependent: str +) -> None: + project_directory = _project_with_component_spec(tmp_path, extra) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "schema_validation_failed" + assert "'build'" in error.value.issue.message + assert dependent in error.value.issue.message + + +@pytest.mark.parametrize( + ("original", "replacement"), + [ + ("command: [pnpm, build]", "command: pnpm build"), + ("command: [pnpm, build]", "command: []"), + ("[pnpm, build]", "[pnpm, build]\n timeoutSeconds: 0"), + ("[pnpm, build]", "[pnpm, build]\n timeoutSeconds: 3601"), + ], + ids=["shell-string", "empty-argv", "timeout-zero", "timeout-too-long"], +) +def test_s002_11_malformed_steps_are_refused( + tmp_path: Path, original: str, replacement: str +) -> None: + project_directory = _project_with_component_spec( + tmp_path, _BUILD_BLOCK.replace(original, replacement) + ) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "schema_validation_failed" From 79db43631f64625adca4d3dd897d4e222f3cd5a1 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:31:44 +0200 Subject: [PATCH 2/2] Refuse control characters in build paths and tools at schema level The schema accepted a tab in workingDirectory or include, and a trailing newline in any build path, tool name or version (JSON Schema $ matches before a final newline), while the SDK value objects refuse them. config validate passed and inventory loading then raised ValueError. A controlCharacterFree def now makes the schema refuse the same inputs. Co-Authored-By: Claude Opus 5.5 --- config/schemas/v1/component.schema.json | 14 +++++++-- sdk/tests/test_component_build_validation.py | 32 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/config/schemas/v1/component.schema.json b/config/schemas/v1/component.schema.json index 215e9a2..b419738 100644 --- a/config/schemas/v1/component.schema.json +++ b/config/schemas/v1/component.schema.json @@ -215,10 +215,14 @@ "buildTools": { "type": "object", "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/controlCharacterFree" + }, "patternProperties": { "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$": { "type": "string", - "pattern": "^[0-9]{1,9}(?:\\.[0-9]{1,9}){0,7}$" + "pattern": "^[0-9]{1,9}(?:\\.[0-9]{1,9}){0,7}$", + "$ref": "#/$defs/controlCharacterFree" } }, "additionalProperties": false @@ -296,7 +300,13 @@ "relativePath": { "type": "string", "minLength": 1, - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", + "$ref": "#/$defs/controlCharacterFree" + }, + "controlCharacterFree": { + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } } }, "required": [ diff --git a/sdk/tests/test_component_build_validation.py b/sdk/tests/test_component_build_validation.py index bf15661..b864db0 100644 --- a/sdk/tests/test_component_build_validation.py +++ b/sdk/tests/test_component_build_validation.py @@ -171,6 +171,38 @@ def test_s002_11_absolute_or_climbing_paths_are_refused( assert error.value.issue.field_path[:2] in {("spec", "build"), ("spec", "artifact")} +@pytest.mark.parametrize( + ("original", "replacement"), + [ + ("workingDirectory: web\n command: [pnpm, build]", + 'workingDirectory: "we\\tb"\n command: [pnpm, build]'), + ("workingDirectory: web\n command: [pnpm, build]", + 'workingDirectory: "web\\n"\n command: [pnpm, build]'), + ("include: [web/dist]", 'include: ["web/dist\\n"]'), + ('pnpm: "9.0.0"', '"pnpm\\n": "9.0.0"'), + ('pnpm: "9.0.0"', 'pnpm: "9.0.0\\n"'), + ], + ids=[ + "tab-in-working-directory", + "newline-ending-working-directory", + "newline-ending-include", + "newline-ending-tool-name", + "newline-ending-tool-version", + ], +) +def test_s002_11_control_characters_the_inventory_refuses_fail_validation( + tmp_path: Path, original: str, replacement: str +) -> None: + project_directory = _project_with_component_spec( + tmp_path, _BUILD_BLOCK.replace(original, replacement) + ) + + with pytest.raises(ConfigValidationError) as error: + validate_config(project_directory, SCHEMAS) + + assert error.value.issue.code == "schema_validation_failed" + + @pytest.mark.parametrize( ("extra", "dependent"), [