import re from urllib.parse import urlsplit from .common import WorkspaceError, read_json, relative_path, safe_path ROLES = {"application", "go-library", "npm-library", "documentation", "guidelines"} FIXED_ROLES = {"application", "documentation", "guidelines"} SHA = re.compile(r"[0-9a-f]{40}\Z") IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z") def validate_repo(repo): if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo): raise WorkspaceError("invalid component repository") if "://" in repo: parsed = urlsplit(repo) if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment: raise WorkspaceError("repository must be a credential-free approved Git URL") if parsed.scheme == "https" and parsed.username: raise WorkspaceError("repository credentials are forbidden") if parsed.scheme == "ssh" and parsed.username not in (None, "git"): raise WorkspaceError("unsupported SSH repository identity") elif not repo.startswith("/"): raise WorkspaceError("repository must be an absolute local path or approved URL") class Manifest: def __init__(self, root): self.root = root document = read_json(root / "components.json") if not isinstance(document, dict) or document.get("schema") != 1 or not isinstance(document.get("components"), list): raise WorkspaceError("unsupported components schema") self.components = {} modules = set() for component in document["components"]: if not isinstance(component, dict): raise WorkspaceError("invalid component record") name = component.get("id") if not isinstance(name, str) or not IDENTIFIER.fullmatch(name) or name in self.components: raise WorkspaceError("invalid or duplicate component id") if component.get("path") != ".workspace/" + name: raise WorkspaceError(f"component path must be .workspace/{name}") safe_path(root, component["path"]) if component.get("legacy_path"): safe_path(root, component["legacy_path"]) if component.get("role") not in ROLES: raise WorkspaceError(f"invalid component role: {name}") package = component.get("package_name") if package is not None and (component["role"] != "npm-library" or not isinstance(package, str) or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*", package)): raise WorkspaceError(f"invalid npm package mapping: {name}") validate_repo(component.get("repo")) target = component.get("target", "main") validate_ref(target) entries = component.get("modules", []) if not isinstance(entries, list): raise WorkspaceError("modules must be a list") for module in entries: if not isinstance(module, dict) or not isinstance(module.get("path"), str) or not module["path"]: raise WorkspaceError("invalid module mapping") if module["path"] in modules or any(c.isspace() for c in module["path"]): raise WorkspaceError("duplicate or invalid module identity") relative_path(module.get("directory", "."), allow_dot=True) modules.add(module["path"]) self.components[name] = component lock = read_json(root / "components.lock.json") if not isinstance(lock, dict) or lock.get("schema") != 1 or not isinstance(lock.get("components"), dict): raise WorkspaceError("unsupported component lock schema") self.lock = lock["components"] for name, value in self.lock.items(): if name not in self.components or not isinstance(value, dict) or not SHA.fullmatch(str(value.get("commit", ""))): raise WorkspaceError(f"invalid locked component: {name}") if self.components[name]["role"] in ("go-library", "npm-library"): raise WorkspaceError(f"language library {name} cannot have a competing source lock") def select(self, name=None, fixed=False): if name: if name not in self.components: raise WorkspaceError(f"unknown component: {name}") return [self.components[name]] return [entry for entry in self.components.values() if not fixed or entry["role"] in FIXED_ROLES] def validate_ref(ref): if not isinstance(ref, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", ref): raise WorkspaceError("invalid branch/ref name") if any(token in ref for token in ("..", "//", "/.", ".lock")) or ref.endswith(("/", ".")): raise WorkspaceError("invalid branch/ref name") def validate_branch(branch): validate_ref(branch) if not branch.startswith(("feature/", "fix/", "chore/", "codex/")): raise WorkspaceError("editing requires a named task branch")