Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Bundled git patches must remain byte-for-byte applicable on Windows.
*.patch text eol=lf
compass text eol=lf
*.sh text eol=lf
*.cmd text eol=crlf
*.ps1 text eol=lf
45 changes: 45 additions & 0 deletions .github/workflows/platform-startup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Platform startup

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
startup:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 25
defaults:
run:
working-directory: Compass smoke
steps:
- uses: actions/checkout@v4
with:
path: Compass smoke
- uses: astral-sh/setup-uv@v6
with:
version: '0.10.5'
python-version: '3.13'
- name: Test native platform behavior
run: uv run --frozen --group dev pytest tests/unit/test_launcher.py tests/unit/test_platform_support.py tests/unit/test_startup.py tests/unit/test_queue.py
- name: Windows first launch and cached setup
if: runner.os == 'Windows'
shell: powershell
run: |
$nodeDir = Split-Path (Get-Command node.exe).Source
$uvDir = Split-Path (Get-Command uv.exe).Source
$env:Path = ($env:Path.Split(';') | Where-Object { $_.TrimEnd('\') -notin @($nodeDir, $uvDir) }) -join ';'
.\compass --setup-only
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
.\compass --setup-only
exit $LASTEXITCODE
- name: POSIX setup
if: runner.os != 'Windows'
run: ./compass --setup-only
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ Download or clone this repository, open its folder in a terminal, and run:
./compass
```

On **Windows**, open the folder in **PowerShell** and run:

```powershell
.\compass
```

Compass installs what it needs, opens the app, and opens a LinkedIn sign-in window
on first use. Sign in, complete **Search criteria**, then choose **Run search**.
Later launches reuse your installation and login.

**Requirements:** macOS or desktop Linux, Git, curl, and internet access for setup.
Linux also needs Chromium's system libraries. The first launch may take a few
**Requirements:** macOS, desktop Linux, or 64-bit Windows 10/11; Git and internet
access for setup. Windows uses its built-in PowerShell; no WSL, Python, or Node
installation is needed. macOS/Linux also need curl. Linux needs Chromium's system
libraries. The first launch may take a few
minutes. Keep the terminal open; **Ctrl+C** stops Compass without deleting saved work.
Running `./compass` again gracefully restarts the previous instance from this
Running the same command again gracefully restarts the previous instance from this
repository and opens the app. Closing the browser tab alone does not stop Compass.

[Startup help](docs/usage-notes.md#startup-help) · [Developer setup](docs/development.md)
Expand Down
166 changes: 29 additions & 137 deletions backend/linkedin_dashboard/db/session.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
from __future__ import annotations

import fcntl
import os
import re
import sqlite3
import stat
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from errno import ELOOP
from functools import lru_cache
from pathlib import Path
from threading import Lock, RLock
Expand Down Expand Up @@ -58,6 +55,31 @@
register_unicode_casefold,
unicode_data_version,
)
from linkedin_dashboard.platform_support.locks import lock_exclusive, unlock
from linkedin_dashboard.platform_support.privacy import (
create_private_directories as _create_private_directories,
)
from linkedin_dashboard.platform_support.privacy import (
open_existing_file as _open_existing_file,
)
from linkedin_dashboard.platform_support.privacy import (
open_owner_only_file as _open_owner_only_file,
)
from linkedin_dashboard.platform_support.privacy import (
require_private_directory as _require_private_directory,
)
from linkedin_dashboard.platform_support.privacy import (
require_same_file as _require_same_file,
)
from linkedin_dashboard.platform_support.privacy import (
require_single_link as _require_single_link,
)
from linkedin_dashboard.platform_support.privacy import (
restore_owner_only_mode as _restore_owner_only_mode,
)
from linkedin_dashboard.platform_support.privacy import (
secure_existing_sidecars as _secure_existing_sidecars,
)
from linkedin_dashboard.settings import normalize_database_path

_MIGRATION_MODULES = (
Expand Down Expand Up @@ -255,7 +277,9 @@ def acquire_worker_lock(self) -> int:
_require_private_directory(lock_path.parent)
descriptor = _open_owner_only_file(lock_path, create=True)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
# One portable exclusive lock: POSIX takes the whole file, and
# Windows a byte outside anything the sidecar ever stores.
lock_exclusive(descriptor)
except OSError as error:
raise BlockingIOError("another queue owner is active") from error
with _WORKER_LOCKS_GUARD:
Expand All @@ -276,7 +300,7 @@ def release_worker_lock(descriptor: int) -> None:
return
_WORKER_LOCK_PATHS.discard(lock_path)
try:
fcntl.flock(descriptor, fcntl.LOCK_UN)
unlock(descriptor)
finally:
os.close(descriptor)

Expand Down Expand Up @@ -519,16 +543,6 @@ def _revalidate_storage(dbapi_connection: Any, path: Path, expected_fd: int) ->
_secure_existing_sidecars(path)


def _restore_owner_only_mode(path: Path, expected_fd: int) -> None:
"""Repair mode through the held, proven inode and verify the result."""
_require_same_file(path, expected_fd)
os.fchmod(expected_fd, stat.S_IRUSR | stat.S_IWUSR)
file_stat = os.fstat(expected_fd)
if stat.S_IMODE(file_stat.st_mode) != 0o600:
raise PermissionError(f"database mode could not be restored to 0600: {path}")
_require_same_file(path, expected_fd)


def _verify_connection_target(
dbapi_connection: Any, path: Path, expected_fd: int
) -> None:
Expand Down Expand Up @@ -558,128 +572,6 @@ def _verify_connection_target(
os.close(actual_fd)


def _create_private_directories(directory: Path) -> None:
"""Create missing DB parents privately without changing existing parents."""
missing: list[Path] = []
cursor = directory
while not cursor.exists():
missing.append(cursor)
cursor = cursor.parent

if not cursor.is_dir():
raise NotADirectoryError(cursor)

for path in reversed(missing):
try:
path.mkdir(mode=0o700)
except FileExistsError:
if not path.is_dir():
raise
else:
os.chmod(path, 0o700)


def _require_private_directory(directory: Path) -> None:
"""Require a private, current-user-owned directory before SQLite writes."""
directory_stat = directory.lstat()
if stat.S_ISLNK(directory_stat.st_mode):
raise PermissionError(
f"database parent must not be a symbolic link: {directory}"
)
if not stat.S_ISDIR(directory_stat.st_mode):
raise NotADirectoryError(directory)

current_uid = getattr(os, "geteuid", os.getuid)()
if directory_stat.st_uid != current_uid:
raise PermissionError(
f"database parent must be owned by the current user: {directory}"
)

mode = stat.S_IMODE(directory_stat.st_mode)
if mode & (stat.S_IRWXG | stat.S_IRWXO):
raise PermissionError(
"database parent must grant no group or world permissions "
f"(expected mode 0700 or stricter): {directory}"
)


def _open_owner_only_file(path: Path, *, create: bool) -> int:
"""Open a regular file without following its final path component."""
flags = (
os.O_RDWR
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0)
)
if create:
try:
descriptor = os.open(path, flags | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
descriptor = _open_existing_file(path, flags)
else:
descriptor = _open_existing_file(path, flags)

try:
file_stat = os.fstat(descriptor)
if not stat.S_ISREG(file_stat.st_mode):
raise ValueError(f"database path is not a regular file: {path}")
_require_same_file(path, descriptor)
os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR)
_require_same_file(path, descriptor)
except Exception:
os.close(descriptor)
raise
return descriptor


def _open_existing_file(path: Path, flags: int) -> int:
try:
return os.open(path, flags)
except OSError as error:
if error.errno == ELOOP:
raise ValueError(
f"database path must not be a symbolic link: {path}"
) from error
raise


def _require_same_file(path: Path, descriptor: int) -> None:
path_stat = path.lstat()
file_stat = os.fstat(descriptor)
_require_single_link(path_stat, path)
_require_single_link(file_stat, path)
if stat.S_ISLNK(path_stat.st_mode) or (
path_stat.st_dev,
path_stat.st_ino,
) != (file_stat.st_dev, file_stat.st_ino):
raise ValueError(f"database path changed while opening it: {path}")


def _require_single_link(file_stat: os.stat_result, path: Path) -> None:
if file_stat.st_nlink != 1:
raise ValueError(f"database file must have exactly one hard link: {path}")


def _secure_existing_sidecars(path: Path) -> None:
for suffix in ("-wal", "-shm", "-journal"):
sidecar = Path(f"{path}{suffix}")
try:
sidecar_stat = sidecar.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(sidecar_stat.st_mode):
raise ValueError(f"database sidecar must not be a symbolic link: {sidecar}")
if not stat.S_ISREG(sidecar_stat.st_mode):
raise ValueError(f"database sidecar is not a regular file: {sidecar}")
_require_single_link(sidecar_stat, sidecar)
try:
descriptor = _open_owner_only_file(sidecar, create=False)
except FileNotFoundError:
continue
else:
os.close(descriptor)


def _sqlite_authorizer(
action: int,
argument_one: str | None,
Expand Down
Loading
Loading