Files
docker-seafile-client/dsc/paths.py
T

101 lines
3.5 KiB
Python
Raw Normal View History

"""Turn server-provided library names into safe local directories.
Library names come from the Seafile server and are untrusted: a name is chosen
by whoever can create a library, and it must never decide where the container
writes. Every name is reduced to a single path component and every resulting
path is proven to stay under the libraries directory.
"""
import logging
import os
from collections import Counter
_lg = logging.getLogger(__name__)
# Whitespace is folded into underscores so that library names survive as
# readable directory names, as they always have.
_WHITESPACE_REPLACEMENT = "_"
# Names that are not usable as a directory of their own.
_RESERVED_NAMES = {"", ".", "..", "..."}
# Length of the library id used to break ties between duplicate names.
_ID_SUFFIX_LEN = 8
class UnsafeLibraryName(ValueError):
"""A library name cannot be mapped to a directory under the parent."""
def lib_dirname(lib_name: str) -> str:
"""
Return the directory name for a library, or raise for a name that cannot
be one. Shell metacharacters are ordinary filename characters here: nothing
reaches a shell any more, so they are kept rather than mangled.
"""
if not isinstance(lib_name, str):
raise UnsafeLibraryName(f"Library name is not a string: {lib_name!r}")
name = "".join(
_WHITESPACE_REPLACEMENT if ch.isspace() else ch
for ch in lib_name.strip()
)
if "\0" in name:
raise UnsafeLibraryName("Library name contains a null byte")
if os.sep in name or (os.altsep and os.altsep in name):
raise UnsafeLibraryName(f"Library name contains a path separator: {lib_name!r}")
if name in _RESERVED_NAMES:
raise UnsafeLibraryName(f"Library name is not usable as a directory: {lib_name!r}")
return name
def resolve_lib_dir(parent_dir: str, dir_name: str) -> str:
"""
Resolve ``dir_name`` inside ``parent_dir`` and prove that the result stays
there. Symlinks are resolved first, so an existing symlink inside the
parent cannot be used to write outside it.
"""
parent = os.path.realpath(parent_dir)
resolved = os.path.realpath(os.path.join(parent, dir_name))
if resolved != parent and not resolved.startswith(parent + os.sep):
raise UnsafeLibraryName(
f"Library directory {resolved} is outside {parent}"
)
if resolved == parent:
raise UnsafeLibraryName(f"Library directory resolves to {parent} itself")
return resolved
def plan_lib_dirs(libraries: dict, parent_dir: str) -> dict:
"""
Map library id to its directory for every library that can safely have one.
Libraries whose names collide after whitespace folding would otherwise
share a directory and corrupt each other, so each of them is suffixed with
the start of its own id. Unsafe names are skipped with a warning rather
than aborting the sync of the remaining libraries.
"""
names = dict()
for lib_id, lib_name in libraries.items():
try:
names[lib_id] = lib_dirname(lib_name)
except UnsafeLibraryName as err:
_lg.warning("Skipping library %s: %s", lib_id, err)
counts = Counter(names.values())
duplicates = {name for name, count in counts.items() if count > 1}
plan = dict()
for lib_id, name in names.items():
if name in duplicates:
name = f"{name}-{lib_id[:_ID_SUFFIX_LEN]}"
try:
plan[lib_id] = resolve_lib_dir(parent_dir, name)
except UnsafeLibraryName as err:
_lg.warning("Skipping library %s: %s", lib_id, err)
return plan