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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Security

- With qsv 26.x or older that has `get` (`qsvdp` or a source build; the prebuilt `qsv` has it
from 27.0.1), `qsv-clientd` points `qsv get` at a cache of its own that only you can read
(`qsv-cache` in the daemon's directory), since those versions make their cache with your
umask's modes and the data may be private. qsv 27 already keeps the cache owner-only on
Unix, so its cache stays where it is; so does a `QSV_CACHE_DIR` you set yourself. This
corrects 0.6.0's note that qsv's cache "follows your umask": that is only true of qsv 26.x
and older.
- `qsv-clientd viz <URL>` and `register --download` through a qsv with `get` no longer leave
the download readable by others while qsv writes it. qsv writes it with your umask's modes,
like its other output files, so 0.6.0 restricted it to you only after qsv was done, in the
folder you chose for the data, where other users might open it in the meantime. qsv now
writes it in a directory only you can enter. Fetch latest was never affected: it downloads
into the daemon's own private directory.

### Changed

- qsv 27.0.0 was withdrawn before its release, so live Data Schematics need **qsv 27.0.1 or
later**, not 27.0.0 as the 0.6.0 notes say. The README, and the message `qsv-clientd serve`
and the tray show with an older qsv, now name 27.0.1. Builds of qsv's development version
that report 27.0.0 have everything 27.0.1 does, and work as it does.

## [0.6.0] - 2026-10-26

This release accompanies qsv 27.0.0, the first qsv whose Data Schematics connect to
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ subprocess pitfalls:
run and control it. See [The qsv-clientd daemon](#the-qsv-clientd-daemon).

qsv itself is not bundled, and **qsv 25.0.0 or later** is required; live Data Schematics
need **qsv 27.0.0 or later** (with an older qsv, `qsv-clientd serve` and the tray say so).
need **qsv 27.0.1 or later** (with an older qsv, `qsv-clientd serve` and the tray say so).
Install it from the [qsv releases](https://github.com/dathere/qsv/releases) or a
[package manager](https://github.com/dathere/qsv#installation-options).

Expand Down Expand Up @@ -129,7 +129,7 @@ client per call costs nothing extra after the first.

`qsv-clientd` (installed with the package) is a per-user daemon that runs qsv for local
processes, through `DaemonClient` / `AsyncDaemonClient` (the same `run()` as `Qsv`), and for
open **Data Schematics** from `qsv viz smart` (qsv 27.0.0 or later). Once you approve a
open **Data Schematics** from `qsv viz smart` (qsv 27.0.1 or later). Once you approve a
schematic with a pairing code, it can drill into the rows behind a chart, refresh its
statistics, re-render itself, run read-only queries and fetch the latest data. Large results
are paged from the daemon, so every row is reachable.
Expand Down
2 changes: 1 addition & 1 deletion docs/DAEMON.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ size or modification time, as with an edit or a fetch with `--allow-replace`. A
a registered dataset by it:

```html
<script type="application/json" id="qsv-schematic-source">{"schema":1,"qsv_version":"27.0.0",
<script type="application/json" id="qsv-schematic-source">{"schema":1,"qsv_version":"27.0.1",
"schematic_uuid":"…","fingerprint":{"algo":"blake3","digest":"…","bytes":123},
"rows":4,"viz_args":["--dict-info"],"redacted":[],"pid":"https://doi.org/…"}</script>
```
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "qsv-client"
version = "0.6.0"
description = "Run the qsv CSV toolkit (25.0.0+) from Python: timeouts that kill the whole process tree, typed errors, capability detection, and a local daemon (with an optional system tray) for live Data Schematics (qsv 27.0.0+)."
description = "Run the qsv CSV toolkit (25.0.0+) from Python: timeouts that kill the whole process tree, typed errors, capability detection, and a local daemon (with an optional system tray) for live Data Schematics (qsv 27.0.1+)."
readme = "README.md"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
Expand Down
10 changes: 9 additions & 1 deletion src/qsv_client/daemon/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@

API_VERSION = 1

# 27.0.0 was withdrawn before release, but builds of qsv master report it and have these
# features, so the version checks use it; what users are told names 27.0.1
LIVE_SCHEMATICS_QSV = "27.0.0"
"""The first qsv whose Data Schematics connect to qsv-clientd."""

LIVE_SCHEMATICS_RELEASE = "27.0.1"
"""The first released qsv whose Data Schematics connect to qsv-clientd."""

PRIVATE_GET_CACHE_QSV = "27.0.0"
"""The first qsv that keeps its `get` cache readable by its owner only (on Unix)."""


def schematics_notice(qsv_version: object) -> str | None:
"""Why schematics won't go live with this qsv, or None when they will (or it can't tell)."""
Expand All @@ -33,7 +41,7 @@ def schematics_notice(qsv_version: object) -> str | None:
return None
return (
f"qsv {qsv_version} writes Data Schematics that can't connect to qsv-clientd. "
f"Live schematics need qsv {LIVE_SCHEMATICS_QSV} or later."
f"Live schematics need qsv {LIVE_SCHEMATICS_RELEASE} or later."
)


Expand Down
45 changes: 34 additions & 11 deletions src/qsv_client/daemon/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@
from typing import TYPE_CHECKING, Any

from .._process import _finish_despite_cancel
from ..capabilities import parse_version
from ._http import HttpError
from .config import ensure_private_dir
from .config import PRIVATE_GET_CACHE_QSV, ensure_private_dir
from .registry import Dataset

if TYPE_CHECKING:
Expand Down Expand Up @@ -129,17 +130,29 @@ async def download(
argv = ["get", url, "--name", name]
if scheme == "ckan":
argv += ["--ckan-api", _ckan_api_for(target)]
# both runs must see the same cache, or cache-fetch can't find the entry
env = _get_cache_env(daemon, caps.version_tuple)
# qsv writes --output with the umask's mode (often 0644), and staging_dir may be
# one others can read, so it writes into a directory only we can enter
private = Path(tempfile.mkdtemp(prefix=".qsv-clientd-", dir=staging_dir))
try:
await daemon._browser_run(ds, argv, timeout=timeout)
await daemon._browser_run(
ds, ["get", "cache-fetch", name, "--output", str(incoming)], timeout=timeout
)
except HttpError as exc:
if exc.code == "qsv_timeout":
raise _timed_out(timeout) from None
raise
# qsv puts a new file at --output with the umask's mode (often 0644), not our 0600
incoming.chmod(0o600)
output = private / incoming.name
try:
await daemon._browser_run(ds, argv, timeout=timeout, env=env)
await daemon._browser_run(
ds,
["get", "cache-fetch", name, "--output", str(output)],
timeout=timeout,
env=env,
)
except HttpError as exc:
if exc.code == "qsv_timeout":
raise _timed_out(timeout) from None
raise
output.chmod(0o600)
os.replace(output, incoming)
finally:
shutil.rmtree(private, ignore_errors=True)
# qsv downloads it, so the size can only be checked once it is here
if incoming.stat().st_size > max_bytes:
raise _too_large(max_bytes)
Expand Down Expand Up @@ -172,6 +185,16 @@ async def download(
return incoming, target.via


def _get_cache_env(daemon: Daemon, qsv_version: tuple[int, int, int]) -> dict[str, str] | None:
"""Before 27.0.0, qsv's get cache follows the umask, and downloads may be private, so give
it one only we can read. A QSV_CACHE_DIR the user set, and qsv 27.0.0+, are left alone."""
if qsv_version >= parse_version(PRIVATE_GET_CACHE_QSV):
return None
if daemon.config.inherit_env and "QSV_CACHE_DIR" in os.environ:
return None
return {"QSV_CACHE_DIR": str(ensure_private_dir(daemon.home / "qsv-cache"))}


async def _fetch_locked(daemon: Daemon, ds: Dataset, source: str, force: bool) -> dict[str, Any]:
cache = ensure_private_dir(daemon.home / "cache" / ds.id)
what = "the source URL" if ds.source_url else "the PID"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_daemon_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,4 @@ def test_schematics_notice_names_qsv_27(version: object, noted: bool) -> None:
notice = config.schematics_notice(version)
assert (notice is not None) is noted
if notice:
assert f"qsv {version} " in notice and "qsv 27.0.0 or later" in notice
assert f"qsv {version} " in notice and "qsv 27.0.1 or later" in notice
84 changes: 79 additions & 5 deletions tests/test_daemon_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,22 +206,96 @@ async def test_fetch_goes_through_qsv_get_when_available(
assert calls[0] == ["get", source, "--name", name]
assert calls[1][:4] == ["get", "cache-fetch", name, "--output"]
staged = Path(calls[1][4])
assert staged.parent == daemon.home / "cache" / ds.id and not staged.exists()
assert calls[2] == ["count", str(staged)]
# qsv writes in a private directory inside the cache, then the file is moved up a level
cache = daemon.home / "cache" / ds.id
assert staged.parent.parent == cache and not staged.parent.exists()
assert calls[2] == ["count", str(cache / staged.name)]
assert _staging_files(daemon, ds) == []


_WRITES_OUTPUT = ' out = sys.argv[sys.argv.index("--output") + 1]\n'


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
async def test_a_download_through_qsv_get_stays_private(
fake_qsv: Callable[[str], str], home: Path, tmp_path: Path
) -> None:
log = tmp_path / "argv.log"
daemon = _daemon(fake_qsv(f"LOG = {str(log)!r}\n" + GET_QSV), home)
log, seen, staging = tmp_path / "argv.log", tmp_path / "seen.json", tmp_path / "staging"
staging.mkdir()
# where qsv writes, and who can enter it, while qsv is still writing
body = GET_QSV.replace(
_WRITES_OUTPUT,
_WRITES_OUTPUT + " d = os.path.dirname(out)\n"
" open(SEEN, 'w').write(json.dumps([d, os.stat(d).st_mode & 0o777]))\n",
)
daemon = _daemon(fake_qsv(f"LOG = {str(log)!r}\nSEEN = {str(seen)!r}\n" + body), home)
staged, _via = await fetch.download(
daemon, "https://example.invalid/d.csv", tmp_path, name="qsv-clientd-x.csv"
daemon, "https://example.invalid/d.csv", staging, name="qsv-clientd-x.csv"
)
assert staged.read_text() == "name\nfetched\n"
assert staged.stat().st_mode & 0o777 == 0o600 # the data may be private
written_in, mode = json.loads(seen.read_text())
# the staging directory may be one others can read, so qsv never writes in it directly
assert Path(written_in).parent == staging
assert mode == 0o700
assert list(staging.iterdir()) == [staged]


async def test_a_failed_qsv_get_leaves_no_private_dir(
fake_qsv: Callable[[str], str], home: Path, tmp_path: Path
) -> None:
log, staging = tmp_path / "argv.log", tmp_path / "staging"
staging.mkdir()
body = GET_QSV.replace(
' os.replace(out + ".tmp", out)\n',
' os.replace(out + ".tmp", out)\n sys.exit(1)\n',
)
daemon = _daemon(fake_qsv(f"LOG = {str(log)!r}\n" + body), home)
with pytest.raises(HttpError):
await fetch.download(
daemon, "https://example.invalid/d.csv", staging, name="qsv-clientd-x.csv"
)
assert list(staging.iterdir()) == []


@pytest.mark.parametrize(
("version", "users_own", "private"),
[("26.0.0", False, True), ("27.0.0", False, False), ("26.0.0", True, False)],
)
async def test_qsv_get_before_27_gets_a_private_cache(
fake_qsv: Callable[[str], str],
home: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
version: str,
users_own: bool,
private: bool,
) -> None:
# qsv before 27.0.0 makes its get cache with the umask's modes
log = tmp_path / "argv.log"
mine = tmp_path / "mine"
if users_own:
monkeypatch.setenv("QSV_CACHE_DIR", str(mine))
else:
monkeypatch.delenv("QSV_CACHE_DIR", raising=False)
body = GET_QSV.replace('"26.0.0"', f'"{version}"').replace(
'fh.write(json.dumps(sys.argv[1:]) + "\\n")',
'fh.write(json.dumps([sys.argv[1:], os.environ.get("QSV_CACHE_DIR")]) + "\\n")',
)
daemon = _daemon(fake_qsv(f"LOG = {str(log)!r}\n" + body), home)
await fetch.download(
daemon, "https://example.invalid/d.csv", tmp_path, name="qsv-clientd-x.csv"
)
calls = [json.loads(line) for line in log.read_text().splitlines()]
cache_dirs = {cache_dir for argv, cache_dir in calls if argv[0] == "get"}
own_cache = daemon.home / "qsv-cache"
if private:
assert cache_dirs == {str(own_cache)} # get and cache-fetch must share one cache
if sys.platform != "win32":
assert own_cache.stat().st_mode & 0o777 == 0o700
else:
assert cache_dirs == {str(mine) if users_own else None}
assert not own_cache.exists()


@pytest.mark.parametrize("ctype", ["text/html", "text/html; charset=utf-8"])
Expand Down
2 changes: 1 addition & 1 deletion tests/test_tray_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ def test_a_qsv_older_than_27_is_noted_once(
(
"Data Schematics won't go live",
"qsv 26.0.0 writes Data Schematics that can't connect to qsv-clientd. "
"Live schematics need qsv 27.0.0 or later.",
"Live schematics need qsv 27.0.1 or later.",
)
]

Expand Down
Loading