gns3-server/gns3server/utils/image_inventory.py

243 lines
8.7 KiB
Python

"""Filesystem primitives shared by image writers and inventory reconciliation."""
import asyncio
import hashlib
import os
import re
import stat
import uuid
from gns3server.config import Config
def normalized_path(path):
return os.path.normcase(os.path.abspath(os.path.expanduser(path)))
def contained_path(path, root):
try:
return os.path.commonpath((normalized_path(path), normalized_path(root))) == normalized_path(root)
except ValueError:
return False
def fingerprint(path):
info = os.stat(path, follow_symlinks=True)
if not stat.S_ISREG(info.st_mode):
raise OSError(f"Not a regular image file: {path}")
return stat_fingerprint(info)
def stat_fingerprint(info):
# Store as text: inode/device numbers need not fit a signed SQL BIGINT.
return ":".join(
str(value) for value in (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
)
class ImageLockBusy(Exception):
pass
class ImageLock:
"""Advisory lock shared by controller processes using the same config directory.
Lock files are deliberately retained: unlinking them permits two processes to
lock different inodes for the same name. OS locks are released on process exit.
"""
def __init__(self, key, wait=True):
self.key = key
self.wait = wait
self._file = None
async def __aenter__(self):
directory = os.path.join(Config.instance().config_dir, ".image-locks")
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, hashlib.sha256(self.key.encode()).hexdigest() + ".lock")
self._file = open(path, "a+b")
if os.name == "nt" and os.fstat(self._file.fileno()).st_size == 0:
self._file.write(b"\0")
self._file.flush()
try:
while True:
try:
if os.name == "nt":
import msvcrt
self._file.seek(0)
msvcrt.locking(self._file.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(self._file, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except (BlockingIOError, PermissionError):
if not self.wait:
raise ImageLockBusy(self.key)
await asyncio.sleep(0.05)
except BaseException:
self._file.close()
self._file = None
raise
async def __aexit__(self, *args):
if self._file is not None:
self._file.close()
self._file = None
def image_lock(path):
return ImageLock("image:" + normalized_path(os.path.realpath(path)))
def publish_image(temporary, destination):
"""Atomically publish a complete file without replacing an existing image.
Both paths must be on the same filesystem. Hard linking provides the atomic
no-overwrite operation that os.replace()/shutil.move() cannot provide.
"""
os.link(temporary, destination)
os.unlink(temporary)
def validate_image_subdirectory(value):
"""A portable relative folder below the server-selected image type root."""
if not value:
return []
parts = value.split("/")
if len(value) > 512 or len(parts) > 8:
raise ValueError("Image subfolder is too long or has more than eight levels")
for part in parts:
if (
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}", part)
or part.endswith((".", " ", ".tmp", ".md5sum"))
or part.lower() in ("lib", "lib64")
or re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", part)
):
raise ValueError(
"Use relative subfolders with letters, numbers, spaces, dots, hyphens or underscores; "
"hidden, reserved and traversal names are not allowed"
)
return parts
class ImageUploadDirectory:
"""Keep upload operations anchored to the authorized directory.
POSIX operations use directory descriptors and never follow descendant
symlinks. The portable fallback rejects symlinks/junctions and rechecks the
directory before each operation. The configured root is administrator-owned.
"""
def __init__(self, root, path):
self.root = os.path.realpath(os.path.expanduser(root))
self.path = os.path.abspath(path)
if contained_path(self.path, normalized_path(root)):
self.path = os.path.join(self.root, os.path.relpath(self.path, normalized_path(root)))
self.fd = None
self.identity = None
self.anchored = (
all(operation in os.supports_dir_fd for operation in (os.open, os.mkdir, os.link, os.unlink))
and hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
)
def __enter__(self):
if not contained_path(self.path, self.root):
raise OSError("Image destination is outside the configured image directory")
os.makedirs(self.root, exist_ok=True)
parts = os.path.relpath(self.path, self.root).split(os.sep)
if parts == ["."]:
parts = []
try:
current = self.root
if self.anchored:
self.fd = os.open(current, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
for part in parts:
if self.anchored:
try:
os.mkdir(part, mode=0o755, dir_fd=self.fd)
except FileExistsError:
pass
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=self.fd)
os.close(self.fd)
self.fd = child
else:
self._check_components(current)
current = os.path.join(current, part)
try:
os.mkdir(current, mode=0o755)
except FileExistsError:
pass
self._check_components(current)
info = os.fstat(self.fd) if self.anchored else os.stat(self.path, follow_symlinks=False)
self.identity = (info.st_dev, info.st_ino)
self.verify()
return self
except BaseException:
self.__exit__()
raise
def _check_components(self, path):
current = self.root
relative = os.path.relpath(path, self.root)
for part in [] if relative == "." else relative.split(os.sep):
current = os.path.join(current, part)
info = os.lstat(current)
reparse = getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
if stat.S_ISLNK(info.st_mode) or reparse:
raise OSError("Image upload folders must not be symlinks or junctions")
def verify(self):
self._check_components(self.path)
info = os.stat(self.path, follow_symlinks=False)
if (info.st_dev, info.st_ino) != self.identity or not contained_path(os.path.realpath(self.path), self.root):
raise OSError("Image upload directory changed during upload")
def temporary(self):
self.verify()
name = f".gns3-upload-{uuid.uuid4().hex}.tmp"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
if self.anchored:
fd = os.open(name, flags | os.O_NOFOLLOW, 0o700, dir_fd=self.fd)
else:
fd = os.open(os.path.join(self.path, name), flags, 0o700)
return fd, name
def publish(self, temporary, filename):
self.verify()
if self.anchored:
os.link(temporary, filename, src_dir_fd=self.fd, dst_dir_fd=self.fd, follow_symlinks=False)
else:
os.link(os.path.join(self.path, temporary), os.path.join(self.path, filename))
self.remove(temporary)
self.verify()
return fingerprint(os.path.join(self.path, filename))
def remove(self, name):
if self.anchored:
os.unlink(name, dir_fd=self.fd)
else:
self.verify()
os.unlink(os.path.join(self.path, name))
def __exit__(self, *args):
if self.fd is not None:
os.close(self.fd)
self.fd = None
def validate_image_upload_name(name):
if (
not name
or len(name) > 255
or name.startswith(".")
or name.endswith((".", " ", ".tmp", ".md5sum"))
or any(ord(c) < 32 or ord(c) == 127 or c in '/\\:%<>"|?*' for c in name)
or re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", name)
):
raise ValueError(
"Subfolder uploads require a plain, non-hidden image filename without path separators or reserved characters"
)