"""Project names, datasets and quotas. WHERE A NAME BECOMES A PATH A project name arrives from the model, which got it from a user, and it goes on to become four different things: a ZFS dataset, a host directory, a Gitea repository and a URL. Each of those has its own way of being wrong - a leading dash becomes an argument, a `..` becomes a different directory, a slash becomes a different repo owner - so the name is checked once, here, against a pattern narrow enough that none of those readings is possible, and checked again by owuivm-launch before it is handed to qemu. That double check is deliberate. `security.py` in the api container says the same thing about URLs: the in-app validation is a usability layer, and the boundary is somewhere else. Here the boundary is this module plus the launcher, and neither trusts the other. It is the same discipline as `matlab.py`, which re-validates a lifted function name against the helper filename pattern at the moment it opens the file - "one check at the boundary is worth more than any number upstream". WHY ZFS AND NOT A DIRECTORY The 10 GB cap has to hold against a guest that is actively trying to fill the array, and `du` in a loop does not. A dataset quota is refused by the filesystem at write time, inside the guest, as ENOSPC. The parent carries exec=off, setuid=off and devices=off, which are inherited: a setuid binary or a device node a guest plants in its share is inert on the host whatever the guest's own mount options said. """ from __future__ import annotations import os import re import subprocess # Lowercase, 2-31 characters, alphanumeric at BOTH ends. No dots (which # would let `..` through and read oddly as a repo name), no underscores # (Gitea allows them but the extra character buys nothing), no uppercase # (ZFS permits it, but two datasets differing only in case on a # case-insensitive path is a bug waiting to be filed). # # The trailing-alphanumeric requirement is not cosmetic: `name-` survives # every path check and then reads as an incomplete word everywhere it is # displayed, and a name ending in a hyphen is the sort of thing that gets # concatenated with a suffix somewhere downstream and stops being unique. PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,29}[a-z0-9]$") # The ZFS pool these live on. /Pool is this project's default and nothing # more - a pool is named when it is created, and `zfs list` will tell you # what yours is called. install.sh writes the chosen value into # /etc/owuivm/vmd.env, which owuivmd.service loads, so the daemon and the # privileged helper always agree about it. # # The dataset name is the mountpoint without the leading slash, which is # the normal ZFS arrangement: pool `tank` mounted at /tank. POOL_ROOT = (os.environ.get("OWUIVM_POOL_ROOT") or "/Pool").rstrip("/") or "/Pool" PROJECTS_DATASET = f"{POOL_ROOT.lstrip('/')}/vmprojects" PROJECTS_ROOT = f"{POOL_ROOT}/vmprojects" QUOTA = os.environ.get("OWUIVM_PROJECT_QUOTA", "10G") # Names that are valid by the pattern but would be confusing or would # collide with something. Kept short on purpose: a long deny list is a # sign the pattern is wrong. RESERVED = frozenset({"base", "iso", "state", "run", "git", "gitea", "owuivm"}) class ProjectError(ValueError): """A project name or dataset problem, phrased for the model to read.""" def validate(name: str) -> str: """Return `name` if it is usable as a dataset, path, repo and URL.""" if not name: raise ProjectError("no project name was given.") if not PROJECT_RE.fullmatch(name): raise ProjectError( f"'{name}' is not a usable project name. Use 2-31 characters, " "lowercase letters, digits and hyphens, starting with a letter " "or digit. The name becomes a folder on the array and a " "repository in Gitea, so it cannot contain '/', '..' or spaces." ) if name in RESERVED: raise ProjectError(f"'{name}' is reserved. Pick another project name.") return name def path_of(name: str) -> str: """The mounted directory for a validated project. The realpath check is not redundant with `validate`. It is what catches the case the pattern cannot see: a symlink planted at /Pool/vmprojects/ by something with write access there, pointing anywhere it likes. """ validate(name) path = os.path.join(PROJECTS_ROOT, name) if os.path.realpath(path) != path: raise ProjectError( f"the path for project '{name}' is not where it should be; refusing to use it." ) return path def _zfs(*args: str) -> subprocess.CompletedProcess: # No shell anywhere near this. Every argument is either a constant or # a name that went through validate(). return subprocess.run( ["/usr/sbin/zfs", *args], capture_output=True, text=True, timeout=60, check=False, ) def exists(name: str) -> bool: validate(name) return _zfs("list", "-H", "-o", "name", f"{PROJECTS_DATASET}/{name}").returncode == 0 def ensure(name: str, owner_uid: int, owner_gid: int) -> str: """Create the project's dataset if it is not there, and return its path.""" validate(name) if not exists(name): # exec/setuid/devices are inherited from the parent; the quota is # per-project and is the only thing standing between one caller # and everybody else's space on the array. result = _zfs("create", "-o", f"quota={QUOTA}", f"{PROJECTS_DATASET}/{name}") if result.returncode != 0: raise ProjectError( f"could not create storage for project '{name}': {result.stderr.strip()}" ) path = path_of(name) os.chown(path, owner_uid, owner_gid) # setgid, so everything the guest writes lands in the group that vmd # and virtiofsd share and both can still read it afterwards. os.chmod(path, 0o2770) return path def usage(name: str) -> tuple[int, int]: """(used bytes, quota bytes) for a project, for the model to be told.""" validate(name) result = _zfs("get", "-Hp", "-o", "value", "used,quota", f"{PROJECTS_DATASET}/{name}") if result.returncode != 0: return (0, 0) values = result.stdout.split() if len(values) < 2: return (0, 0) return (int(values[0]), int(values[1])) def describe_usage(name: str) -> str: used, quota = usage(name) if not quota: return "" pct = 100.0 * used / quota line = f"Project storage: {used / 1e9:.2f} GB of {quota / 1e9:.0f} GB used ({pct:.0f}%)." if pct >= 90: # Said plainly rather than as a warning the model can round off: # at the quota, writes fail inside the guest as ENOSPC, and a # build failing for that reason looks exactly like a build # failing for a real one. line += ( " At 100% the guest cannot write at all and every command that" " creates a file will fail with 'No space left on device'." ) return line