mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-08 19:46:21 +03:00
243 lines
8.7 KiB
Python
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"
|
|
)
|