code review jr: store path traversal...
emit workflow_run_failed again?
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .models import WorkflowArtifact, WorkflowDeployment
|
||||
|
||||
STORE_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
|
||||
|
||||
|
||||
def ensure_store_id(value: str, *, field_name: str) -> str:
|
||||
"""Reject ids that cannot safely map to one local store path component."""
|
||||
if not re.fullmatch(STORE_ID_PATTERN, value):
|
||||
raise ValueError(
|
||||
f"{field_name} must start with alphanumeric or underscore and contain "
|
||||
"only [A-Za-z0-9_.-]"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowArtifactStore:
|
||||
"""Storage boundary for workflow artifacts and deployments."""
|
||||
@@ -51,16 +64,16 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
|
||||
return self.root / "deployments"
|
||||
|
||||
def save_artifact(self, artifact: WorkflowArtifact) -> None:
|
||||
artifact_dir = self.artifacts_dir / artifact.id
|
||||
artifact_dir = self._artifact_dir(artifact.id)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = artifact_dir / f"{artifact.version}.json"
|
||||
path = artifact_dir / self._artifact_filename(artifact.version)
|
||||
path.write_text(
|
||||
json.dumps(artifact.model_dump(mode="json"), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_artifact(self, artifact_id: str, version: int) -> WorkflowArtifact:
|
||||
path = self.artifacts_dir / artifact_id / f"{version}.json"
|
||||
path = self._artifact_dir(artifact_id) / self._artifact_filename(version)
|
||||
if not path.exists():
|
||||
raise KeyError(f"unknown workflow artifact {artifact_id}@{version}")
|
||||
return WorkflowArtifact.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
@@ -74,9 +87,10 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
|
||||
return artifacts
|
||||
|
||||
def resolve_latest(self, artifact_id: str) -> WorkflowArtifact:
|
||||
artifact_dir = self._artifact_dir(artifact_id)
|
||||
versions = [
|
||||
int(path.stem)
|
||||
for path in (self.artifacts_dir / artifact_id).glob("*.json")
|
||||
for path in artifact_dir.glob("*.json")
|
||||
if path.stem.isdecimal()
|
||||
]
|
||||
if not versions:
|
||||
@@ -84,14 +98,14 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
|
||||
return self.get_artifact(artifact_id, max(versions))
|
||||
|
||||
def save_deployment(self, deployment: WorkflowDeployment) -> None:
|
||||
path = self.deployments_dir / f"{deployment.id}.json"
|
||||
path = self._deployment_path(deployment.id)
|
||||
path.write_text(
|
||||
json.dumps(deployment.model_dump(mode="json"), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> WorkflowDeployment:
|
||||
path = self.deployments_dir / f"{deployment_id}.json"
|
||||
path = self._deployment_path(deployment_id)
|
||||
if not path.exists():
|
||||
raise KeyError(f"unknown workflow deployment {deployment_id!r}")
|
||||
return WorkflowDeployment.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
@@ -106,7 +120,31 @@ class FileWorkflowArtifactStore(WorkflowArtifactStore):
|
||||
|
||||
def delete_deployment(self, deployment_id: str) -> None:
|
||||
"""Remove one mutable deployment binding record from the store."""
|
||||
path = self.deployments_dir / f"{deployment_id}.json"
|
||||
path = self._deployment_path(deployment_id)
|
||||
if not path.exists():
|
||||
raise KeyError(f"unknown workflow deployment {deployment_id!r}")
|
||||
path.unlink()
|
||||
|
||||
def _artifact_dir(self, artifact_id: str) -> Path:
|
||||
safe_id = ensure_store_id(artifact_id, field_name="artifact_id")
|
||||
root = self.artifacts_dir.resolve()
|
||||
path = (self.artifacts_dir / safe_id).resolve()
|
||||
if path.parent != root:
|
||||
raise ValueError(f"artifact_id escapes artifact store: {artifact_id!r}")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _artifact_filename(version: int) -> str:
|
||||
if version < 1:
|
||||
raise ValueError("artifact version must be >= 1")
|
||||
return f"{version}.json"
|
||||
|
||||
def _deployment_path(self, deployment_id: str) -> Path:
|
||||
safe_id = ensure_store_id(deployment_id, field_name="deployment_id")
|
||||
root = self.deployments_dir.resolve()
|
||||
path = (self.deployments_dir / f"{safe_id}.json").resolve()
|
||||
if path.parent != root:
|
||||
raise ValueError(
|
||||
f"deployment_id escapes deployment store: {deployment_id!r}"
|
||||
)
|
||||
return path
|
||||
|
||||
@@ -9,6 +9,7 @@ from wf_authoring import NodeSpec
|
||||
from wf_core import (
|
||||
NodeUse,
|
||||
RunState,
|
||||
RunStatus,
|
||||
Workflow,
|
||||
execute_workflow_result_async,
|
||||
resume_workflow_result_async,
|
||||
@@ -168,7 +169,9 @@ class WorkflowRuntimeService:
|
||||
)
|
||||
self.emit_event(
|
||||
make_event(
|
||||
"workflow_run_completed",
|
||||
"workflow_run_failed"
|
||||
if run.status == RunStatus.FAILED
|
||||
else "workflow_run_completed",
|
||||
workflow_name=plan.name,
|
||||
payload={"status": run.status.value},
|
||||
)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .models import ConnectionConfig
|
||||
|
||||
CONNECTION_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
|
||||
|
||||
|
||||
def parse_connection_id(connection_id: str) -> tuple[str, str]:
|
||||
# Connection ids are logical source ids, but they also key persisted auth and
|
||||
# catalog files. Keep this parser conservative so unsafe ids are rejected
|
||||
# before they reach either registry or store boundaries.
|
||||
if not re.fullmatch(CONNECTION_ID_PATTERN, connection_id):
|
||||
raise ValueError(
|
||||
"connection id must start with alphanumeric or underscore and contain "
|
||||
"only [A-Za-z0-9_.-]"
|
||||
)
|
||||
if "." not in connection_id:
|
||||
raise ValueError("connection id must look like '<server>.<account>'")
|
||||
server, account = connection_id.split(".", 1)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..connections import parse_connection_id
|
||||
from ..models import (
|
||||
AuthRecord,
|
||||
CatalogNodeEntry,
|
||||
@@ -43,10 +44,22 @@ class FileStore(Store):
|
||||
return self.root / "catalog"
|
||||
|
||||
def _auth_path(self, connection_id: str) -> Path:
|
||||
return self.auth_dir / f"{connection_id}.json"
|
||||
return self._connection_path(self.auth_dir, connection_id)
|
||||
|
||||
def _catalog_path(self, connection_id: str) -> Path:
|
||||
return self.catalog_dir / f"{connection_id}.json"
|
||||
return self._connection_path(self.catalog_dir, connection_id)
|
||||
|
||||
@staticmethod
|
||||
def _connection_path(directory: Path, connection_id: str) -> Path:
|
||||
"""Map one validated connection id to one file inside a store directory."""
|
||||
parse_connection_id(connection_id)
|
||||
root = directory.resolve()
|
||||
path = (directory / f"{connection_id}.json").resolve()
|
||||
if path.parent != root:
|
||||
raise ValueError(
|
||||
f"connection id escapes store directory: {connection_id!r}"
|
||||
)
|
||||
return path
|
||||
|
||||
def save_auth(self, record: AuthRecord) -> None:
|
||||
self._auth_path(record.connection_id).write_text(
|
||||
|
||||
Reference in New Issue
Block a user