Skip to content
Merged
5 changes: 1 addition & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,6 @@ jobs:
name: test (Python 3.11, ${{ matrix.os }}, syntax=${{ matrix.syntax }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
# Pre-existing Windows full-suite failures are listed in this PR; WP-3 and WP-5 regressions
# remain required in windows-platforms below.
continue-on-error: ${{ matrix.os == 'windows-latest' }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -100,7 +97,7 @@ jobs:
- run: uv sync --locked
- name: Confirm spaCy is absent
run: uv run python -c "import importlib.util, sys; sys.exit(importlib.util.find_spec('spacy') is not None)"
- run: uv run pytest -n auto tests/test_platforms.py tests/test_module_ownership.py tests/test_report_compatibility.py tests/test_cli_onboarding.py tests/test_input_robustness.py tests/test_demo.py tests/test_library_start.py tests/test_build_speed.py tests/test_concise_output.py tests/test_cache.py::test_a_relative_xdg_cache_home_is_ignored tests/test_throughput.py::test_memory_jobs_follow_physical_memory
- run: uv run pytest -n auto tests/test_platforms.py tests/test_windows_release.py tests/test_module_ownership.py tests/test_report_compatibility.py tests/test_cli_onboarding.py tests/test_input_robustness.py tests/test_demo.py tests/test_library_start.py tests/test_build_speed.py tests/test_concise_output.py tests/test_cache.py::test_a_relative_xdg_cache_home_is_ignored tests/test_throughput.py::test_memory_jobs_follow_physical_memory
- run: uv run pytest 'tests/test_snapshots.py::test_snapshot[surface-demo]' tests/test_snapshots.py::test_demo_snapshot_is_independent_of_disk_cache

package:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ then `styleprofile setup` for the parser-based metrics.

### Fixed

- **Windows paths and cache privacy.** Directory IDs and ordering are portable, home
directory names stay out of saved reports, and cache files use native private ACLs.
A cache that cannot be protected is left out while measurements continue.

- **References need at least two chunks.** Build refuses smaller references and suggests
adding documents or reducing the window size. Incomparable library results are not
judged, and any CLI fail flag rejects them with exit status 3. Too-short texts still
Expand Down
26 changes: 21 additions & 5 deletions src/styleprofile/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import time
import zlib
from collections.abc import Callable, Sequence
from contextlib import closing
from dataclasses import dataclass
from functools import cache
from importlib import import_module
Expand Down Expand Up @@ -199,6 +200,7 @@ def __init__(
import queue

self.path = path if path is not None else cache_dir() / FILENAME
self._owned_directory = path is None or self.path.parent == cache_dir()
self.max_bytes = max_bytes
self.clock = clock
self.problem: str | None = None
Expand Down Expand Up @@ -250,7 +252,15 @@ def _open_or_reset(self) -> sqlite3.Connection | None:
def _open(self) -> sqlite3.Connection:
import sqlite3

if sys.platform == "win32":
from styleprofile.cache_acl import check_cache_paths

check_cache_paths(self.path, owned_directory=self._owned_directory)
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if sys.platform == "win32":
from styleprofile.cache_acl import protect_cache

protect_cache(self.path, owned_directory=self._owned_directory)
# The file holds pattern counts of the user's texts: private, as they are.
if not self.path.exists():
os.close(os.open(self.path, os.O_WRONLY | os.O_CREAT, 0o600))
Expand All @@ -262,13 +272,16 @@ def _open(self) -> sqlite3.Connection:
# time (4 KB at a time took several times longer on a busy macOS disk), and
# pages freed by pruning go back to the file system.
connection.execute(f"PRAGMA page_size={PAGE_SIZE}")
# Set the connection's journal policy before auto_vacuum, which can write
# even when its value is unchanged. A new connection defaults to DELETE:
# on Windows it cannot delete a journal another PERSIST connection holds.
connection.execute("PRAGMA journal_mode=PERSIST")
connection.execute("PRAGMA auto_vacuum=INCREMENTAL")
new = connection.execute("PRAGMA user_version").fetchall()[0][0] != FORMAT
# A rollback journal kept between writes rather than deleted: a run that only
# reads opens one file and writes none, and writes don't create a file each time.
# On a busy disk, creating and growing files is what is slow: a write-ahead log
# made reading a warm cache 0.1-0.3 s slower.
connection.execute("PRAGMA journal_mode=PERSIST")
connection.execute(f"PRAGMA journal_size_limit={JOURNAL_LIMIT}")
# A cache need not survive a power cut, so writes are never synced to disk; a
# file damaged that way is started afresh (``_open_or_reset``).
Expand Down Expand Up @@ -416,8 +429,10 @@ def close(self) -> None:

self.flush()
if self._writer is not None:
# A writer already given up on is not waited for (it is a daemon thread).
if self.problem is None and self._hand(None):
# A failed writer still drains its queue. Give it the stop signal too,
# so it closes its connection instead of waiting forever for another batch.
# Both handing off and waiting remain bounded for an unresponsive writer.
if self._hand(None):
self._wait_for_writer(self._writer)
self._writer = None
if self._connection is not None:
Expand Down Expand Up @@ -526,9 +541,10 @@ def describe(path: Path | None = None) -> tuple[Path, int, int, str | None]:
problem = f"{existing} is not writable"
if base.exists():
try:
with sqlite3.connect(f"file:{base}?mode=ro", uri=True, timeout=TIMEOUT_S) as db:
with closing(
sqlite3.connect(base.absolute().as_uri() + "?mode=ro", uri=True, timeout=TIMEOUT_S)
) as db:
entries = db.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
db.close()
except sqlite3.Error as error:
problem = problem or str(error)
entries = 0
Expand Down
227 changes: 227 additions & 0 deletions src/styleprofile/cache_acl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Windows cache privacy, using native ACLs without an optional dependency.

Only the app's cache directory and its own database files are changed. A caller's
parent directory is inspected, never rewritten. Failure disables caching upstream.
"""

from __future__ import annotations

import ctypes
import os
import stat
from pathlib import Path
from typing import Any

_native: Any = ctypes


class _WindowsACL:
def __init__(self) -> None:
# Imported lazily: scores and non-Windows runs do not load Windows libraries.
from ctypes import wintypes

self.api: Any = _native.WinDLL("advapi32", use_last_error=True)
self.kernel: Any = _native.WinDLL("kernel32", use_last_error=True)
pointer = ctypes.c_void_p
self.kernel.GetCurrentProcess.restype = wintypes.HANDLE
self.kernel.CloseHandle.argtypes = [wintypes.HANDLE]
self.kernel.LocalFree.argtypes = [pointer]
self.kernel.LocalFree.restype = pointer
signatures = {
"OpenProcessToken": (
[wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(pointer)],
wintypes.BOOL,
),
"GetTokenInformation": (
[pointer, ctypes.c_int, pointer, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)],
wintypes.BOOL,
),
"ConvertSidToStringSidW": ([pointer, ctypes.POINTER(pointer)], wintypes.BOOL),
"ConvertStringSecurityDescriptorToSecurityDescriptorW": (
[wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(pointer), pointer],
wintypes.BOOL,
),
"ConvertSecurityDescriptorToStringSecurityDescriptorW": (
[pointer, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(pointer), pointer],
wintypes.BOOL,
),
"GetNamedSecurityInfoW": (
[
wintypes.LPCWSTR,
ctypes.c_int,
wintypes.DWORD,
ctypes.POINTER(pointer),
pointer,
pointer,
pointer,
ctypes.POINTER(pointer),
],
wintypes.DWORD,
),
"SetFileSecurityW": ([wintypes.LPCWSTR, wintypes.DWORD, pointer], wintypes.BOOL),
"GetAce": ([pointer, wintypes.DWORD, ctypes.POINTER(pointer)], wintypes.BOOL),
}
for name, (arguments, result) in signatures.items():
function = getattr(self.api, name)
function.argtypes = arguments
function.restype = result
token = pointer()
self._check(
self.api.OpenProcessToken(self.kernel.GetCurrentProcess(), 8, ctypes.byref(token))
)
try:
size = wintypes.DWORD()
self.api.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
if not size.value:
self._check(False)
data = ctypes.create_string_buffer(size.value)
self._check(self.api.GetTokenInformation(token, 1, data, size, ctypes.byref(size)))
self.user = self._sid(pointer.from_buffer(data).value)
finally:
self.kernel.CloseHandle(token)

@staticmethod
def _check(success: Any) -> None:
if not success:
raise _native.WinError(_native.get_last_error())

def _sid(self, value: Any) -> str:
text = ctypes.c_void_p()
self._check(self.api.ConvertSidToStringSidW(value, ctypes.byref(text)))
try:
return ctypes.wstring_at(text)
finally:
self.kernel.LocalFree(text)

def _read(self, path: Path) -> tuple[Any, Any, Any]:
owner, dacl, descriptor = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p()
status = self.api.GetNamedSecurityInfoW(
str(path),
1,
5,
ctypes.byref(owner),
None,
ctypes.byref(dacl),
None,
ctypes.byref(descriptor),
)
if status:
raise _native.WinError(status)
return owner, dacl, descriptor

def read(self, path: Path) -> tuple[str, str]:
owner, _, descriptor = self._read(path)
text = ctypes.c_void_p()
try:
self._check(
self.api.ConvertSecurityDescriptorToStringSecurityDescriptorW(
descriptor, 1, 4, ctypes.byref(text), None
)
)
return self._sid(owner), ctypes.wstring_at(text)
finally:
if text.value:
self.kernel.LocalFree(text)
self.kernel.LocalFree(descriptor)

def protect(self, path: Path, *, directory: bool) -> None:
owner, _ = self.read(path)
if owner not in {self.user, "S-1-5-18", "S-1-5-32-544"}:
raise PermissionError(f"the cache path belongs to another user: {path}")
inherit = "OICI" if directory else ""
dacl = "D:P" + "".join(f"(A;{inherit};FA;;;{sid})" for sid in (self.user, "SY", "BA"))
descriptor = ctypes.c_void_p()
self._check(
self.api.ConvertStringSecurityDescriptorToSecurityDescriptorW(
dacl, 1, ctypes.byref(descriptor), None
)
)
try:
# SetFileSecurity changes this object; unlike SetNamedSecurityInfo, it
# does not propagate new permissions to unrelated existing children.
self._check(self.api.SetFileSecurityW(str(path), 0x80000004, descriptor))
finally:
self.kernel.LocalFree(descriptor)
self.verify(path, directory=directory)

def _entries(self, path: Path) -> tuple[str, list[tuple[int, int, int, str]]]:
"""Read numeric SIDs and access masks, without SDDL's account aliases."""
owner, dacl, descriptor = self._read(path)
try:
if not dacl.value: # A null DACL grants everyone full access.
raise PermissionError(f"the cache has no access control list: {path}")
# ACL's AceCount is a WORD at offset 4 (the fixed ACL header is 8 bytes).
count = ctypes.c_uint16.from_address(dacl.value + 4).value
entries = []
for index in range(count):
ace = ctypes.c_void_p()
self._check(self.api.GetAce(dacl, index, ctypes.byref(ace)))
address = ace.value
if address is None:
raise PermissionError(f"the cache ACL cannot be verified: {path}")
kind = ctypes.c_ubyte.from_address(address).value
flags = ctypes.c_ubyte.from_address(address + 1).value
if kind != 0: # Only ordinary ACCESS_ALLOWED_ACE has this layout.
raise PermissionError(f"the cache ACL cannot be verified: {path}")
mask = ctypes.c_uint32.from_address(address + 4).value
entries.append((kind, flags, mask, self._sid(address + 8)))
return self._sid(owner), entries
finally:
self.kernel.LocalFree(descriptor)

def verify(self, path: Path, *, directory: bool = False) -> None:
owner, entries = self._entries(path)
if owner not in {self.user, "S-1-5-18", "S-1-5-32-544"}:
raise PermissionError(f"the cache path belongs to another user: {path}")
allowed = {self.user, "S-1-5-18", "S-1-5-32-544"}
user_access = False
user_inheritance = False
for kind, flags, rights, sid in entries:
if kind != 0 or sid not in allowed:
raise PermissionError(f"the cache ACL grants access beyond this user: {path}")
if sid == self.user and not flags & 8 and rights in {0x1F01FF, 0x10000000}:
user_access = True
user_inheritance |= flags & 3 == 3
if not entries or not user_access or (directory and not user_inheritance):
raise PermissionError(f"the cache ACL does not grant this user full access: {path}")


def check_cache_paths(path: Path, *, owned_directory: bool) -> list[Path]:
"""Refuse existing reparse points before creating directories or changing ACLs."""
directories = [path.parent]
if (
owned_directory
and path.parent.name == "Cache"
and path.parent.parent.name == "styleprofile"
):
directories.insert(0, path.parent.parent)
files = [
Path(f"{path}{suffix}")
for suffix in ("", "-journal", "-wal", "-shm")
if os.path.lexists(f"{path}{suffix}")
]
# Check the entire path chain: lstat of a leaf does not expose a linked ancestor.
for item in dict.fromkeys([*directories, *path.parent.parents, *files]):
try:
info = item.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
raise PermissionError(f"the cache path is a link or reparse point: {item}")
if item in files and info.st_nlink != 1:
raise PermissionError(f"the cache path has multiple hard links: {item}")
return files


def protect_cache(path: Path, *, owned_directory: bool) -> None:
"""Protect the owned directory and existing database/sidecars, or fail closed."""
files = check_cache_paths(path, owned_directory=owned_directory)
acl = _WindowsACL()
if owned_directory:
if path.parent.name == "Cache" and path.parent.parent.name == "styleprofile":
acl.protect(path.parent.parent, directory=True)
acl.protect(path.parent, directory=True)
else:
acl.verify(path.parent, directory=True)
for item in files:
acl.protect(item, directory=False)
27 changes: 20 additions & 7 deletions src/styleprofile/corpus/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from pathlib import Path, PurePath, PurePosixPath

from styleprofile.core import Note, NoteCode, StyleProfileError
from styleprofile.corpus.ids import _GROUPED, _PART_SUFFIX, _RECORD, document_of, group_id
Expand Down Expand Up @@ -256,7 +256,17 @@ def expand_path(value: str) -> Path:
"""A path as typed with ``~`` expanded, as an error rather than a crash when it names an
unknown user (``~other``)."""
try:
return Path(value).expanduser()
expanded = Path(value).expanduser()
user = re.split(r"[/\\]", value, maxsplit=1)[0]
# Windows infers another user's path without checking that home exists.
if (
os.name == "nt"
and user.startswith("~")
and len(user) > 1
and not Path(user).expanduser().is_dir()
):
raise StyleProfileError(f"{value}: cannot find that user's home", code="not_found")
return expanded
except RuntimeError as error:
raise StyleProfileError(f"{value}: {error}", code="not_found") from error

Expand Down Expand Up @@ -286,11 +296,14 @@ def root_name(value: str) -> str:
OTHER_HOMES = (Path("/root"),)


def _is_home(absolute: Path) -> bool:
def _is_home(absolute: PurePath) -> bool:
"""Whether ``absolute`` is a home directory, whose name is a user name."""
if absolute == Path(os.path.abspath(Path.home())) or absolute in OTHER_HOMES:
root = absolute.parents[-1] if absolute.parents else absolute
if absolute == Path(os.path.abspath(Path.home())) or absolute in (
root / home.name for home in OTHER_HOMES
):
return True
return absolute.parent in HOME_PARENTS
return absolute.parent in (root / parent.name for parent in HOME_PARENTS)


@dataclass
Expand Down Expand Up @@ -502,7 +515,7 @@ def _walk(path: Path) -> tuple[list[Path], list[Path], list[str]]:
readable: list[Path] = []
skipped: list[Path] = []
generated: list[str] = []
for item in sorted(path.rglob("*")):
for item in sorted(path.rglob("*"), key=lambda item: item.parts):
parts = item.relative_to(path).parts
if not item.is_file() or any(
part.startswith(".") or part in SKIPPED_DIRS for part in parts[:-1]
Expand Down Expand Up @@ -618,7 +631,7 @@ def load_chunks(
)
sources = _name_sources(value, path, files, names)
for item, source in zip(files, sources, strict=True):
chunk_id = str(item.relative_to(path))
chunk_id = item.relative_to(path).as_posix()
label = str(Path(value) / chunk_id)
chunks.extend(
_file_chunks(
Expand Down
Loading
Loading