json-database 1.0.3a1__tar.gz → 1.0.4a2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {json_database-1.0.3a1 → json_database-1.0.4a2}/PKG-INFO +1 -1
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/__init__.py +59 -3
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/version.py +2 -2
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/PKG-INFO +1 -1
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_storage.py +118 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/LICENSE +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/README.md +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/crypto.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/exceptions.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/search.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/utils.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/xdg_utils.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/SOURCES.txt +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/dependency_links.txt +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/requires.txt +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/top_level.txt +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/pyproject.toml +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/setup.cfg +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_crypto.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_database.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_e2e_user_database.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_encrypted_storage.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_exceptions.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_query.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_search.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_xdg.py +0 -0
- {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_xdg_utils.py +0 -0
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
from os import makedirs, remove
|
|
5
5
|
from os.path import expanduser, isdir, dirname, exists, isfile, join
|
|
6
6
|
from pprint import pprint
|
|
7
|
-
from tempfile import gettempdir
|
|
7
|
+
from tempfile import gettempdir, mkstemp
|
|
8
8
|
|
|
9
9
|
from combo_lock import ComboLock
|
|
10
10
|
|
|
@@ -135,8 +135,64 @@ class JsonStorage(dict):
|
|
|
135
135
|
path = expanduser(path)
|
|
136
136
|
if dirname(path) and not isdir(dirname(path)):
|
|
137
137
|
makedirs(dirname(path))
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
self._atomic_write(path, json.dumps(self, indent=4,
|
|
139
|
+
ensure_ascii=False))
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _atomic_write(path, data):
|
|
143
|
+
"""Replace the file at ``path`` with ``data`` in one step.
|
|
144
|
+
|
|
145
|
+
Writing directly into the destination truncates it before the new
|
|
146
|
+
content is on disk, so a power cut or a full disk leaves a
|
|
147
|
+
half-written file that no longer parses. Instead the data goes to a
|
|
148
|
+
temporary file in the same directory, is flushed all the way to the
|
|
149
|
+
disk, and then replaces the destination with ``os.replace``, which
|
|
150
|
+
is atomic. A reader always sees either the old file or the new one.
|
|
151
|
+
|
|
152
|
+
The temporary file sits beside the destination until the replace,
|
|
153
|
+
so a store needs room for both copies. On a full partition even a
|
|
154
|
+
write that makes the file smaller can now fail.
|
|
155
|
+
"""
|
|
156
|
+
# Follow the symlink and replace what it points at. os.replace on
|
|
157
|
+
# the link would put a regular file where the link was, quietly
|
|
158
|
+
# detaching the file the rest of the system shares.
|
|
159
|
+
real_path = os.path.realpath(path)
|
|
160
|
+
directory = dirname(real_path) or "."
|
|
161
|
+
if exists(real_path) and not os.access(real_path, os.W_OK):
|
|
162
|
+
# os.replace only needs write access to the directory, so a
|
|
163
|
+
# read-only destination would otherwise be overwritten. Refuse
|
|
164
|
+
# it, the same way a plain open(path, 'w') does.
|
|
165
|
+
raise PermissionError(f"file is not writable: {path}")
|
|
166
|
+
fd, tmp_path = mkstemp(dir=directory, prefix=".tmp_", suffix=".json")
|
|
167
|
+
try:
|
|
168
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
169
|
+
f.write(data)
|
|
170
|
+
f.flush()
|
|
171
|
+
os.fsync(f.fileno())
|
|
172
|
+
if exists(real_path):
|
|
173
|
+
stat = os.stat(real_path)
|
|
174
|
+
# mkstemp creates the temp file 0600; keep the permissions
|
|
175
|
+
# the destination already had
|
|
176
|
+
os.chmod(tmp_path, stat.st_mode & 0o777)
|
|
177
|
+
if os.name == "posix" and os.geteuid() == 0:
|
|
178
|
+
# os.replace keeps the temp file's owner, so a root run
|
|
179
|
+
# would hand the file to root and lock the service that
|
|
180
|
+
# owns it out. Only root can give a file away; for any
|
|
181
|
+
# other user the owner cannot change anyway.
|
|
182
|
+
os.chown(tmp_path, stat.st_uid, stat.st_gid)
|
|
183
|
+
os.replace(tmp_path, real_path)
|
|
184
|
+
except BaseException:
|
|
185
|
+
if exists(tmp_path):
|
|
186
|
+
remove(tmp_path)
|
|
187
|
+
raise
|
|
188
|
+
if os.name == "posix":
|
|
189
|
+
# the rename itself is only on disk once the directory is
|
|
190
|
+
# flushed; without this a crash can bring back the old file
|
|
191
|
+
dir_fd = os.open(directory, os.O_RDONLY)
|
|
192
|
+
try:
|
|
193
|
+
os.fsync(dir_fd)
|
|
194
|
+
finally:
|
|
195
|
+
os.close(dir_fd)
|
|
140
196
|
|
|
141
197
|
def remove(self):
|
|
142
198
|
with self.lock:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# START_VERSION_BLOCK
|
|
2
2
|
VERSION_MAJOR = 1
|
|
3
3
|
VERSION_MINOR = 0
|
|
4
|
-
VERSION_BUILD =
|
|
5
|
-
VERSION_ALPHA =
|
|
4
|
+
VERSION_BUILD = 4
|
|
5
|
+
VERSION_ALPHA = 2
|
|
6
6
|
# END_VERSION_BLOCK
|
|
7
7
|
|
|
8
8
|
__version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
|
|
@@ -390,3 +390,121 @@ class TestJsonStorage:
|
|
|
390
390
|
with open(temp_db_path, 'r') as f:
|
|
391
391
|
data = json.load(f)
|
|
392
392
|
assert data == {}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class TestJsonStorageAtomicWrite:
|
|
396
|
+
"""store() must never leave a half-written file on disk."""
|
|
397
|
+
|
|
398
|
+
def test_crash_before_replace_keeps_original(self, temp_db_path,
|
|
399
|
+
monkeypatch):
|
|
400
|
+
"""A crash after the temp file is written keeps the old file intact."""
|
|
401
|
+
storage = JsonStorage(temp_db_path, disable_lock=True)
|
|
402
|
+
storage["key"] = "original"
|
|
403
|
+
storage.store()
|
|
404
|
+
|
|
405
|
+
def boom(src, dst):
|
|
406
|
+
raise OSError("simulated power cut")
|
|
407
|
+
|
|
408
|
+
monkeypatch.setattr(os, "replace", boom)
|
|
409
|
+
storage["key"] = "new value"
|
|
410
|
+
with pytest.raises(OSError):
|
|
411
|
+
storage.store()
|
|
412
|
+
|
|
413
|
+
with open(temp_db_path, 'r') as f:
|
|
414
|
+
data = json.load(f)
|
|
415
|
+
assert data == {"key": "original"}
|
|
416
|
+
|
|
417
|
+
def test_crash_before_replace_leaves_no_temp_file(self, temp_db_path,
|
|
418
|
+
monkeypatch):
|
|
419
|
+
"""The temp file is cleaned up when the replace fails."""
|
|
420
|
+
storage = JsonStorage(temp_db_path, disable_lock=True)
|
|
421
|
+
storage["key"] = "original"
|
|
422
|
+
storage.store()
|
|
423
|
+
|
|
424
|
+
def boom(src, dst):
|
|
425
|
+
raise OSError("simulated power cut")
|
|
426
|
+
|
|
427
|
+
monkeypatch.setattr(os, "replace", boom)
|
|
428
|
+
with pytest.raises(OSError):
|
|
429
|
+
storage.store()
|
|
430
|
+
|
|
431
|
+
folder = os.path.dirname(temp_db_path)
|
|
432
|
+
assert [f for f in os.listdir(folder) if f.startswith(".tmp_")] == []
|
|
433
|
+
|
|
434
|
+
def test_store_writes_through_replace(self, temp_db_path, monkeypatch):
|
|
435
|
+
"""The destination is only ever touched by os.replace."""
|
|
436
|
+
calls = []
|
|
437
|
+
real_replace = os.replace
|
|
438
|
+
|
|
439
|
+
def spy(src, dst):
|
|
440
|
+
calls.append((src, dst))
|
|
441
|
+
real_replace(src, dst)
|
|
442
|
+
|
|
443
|
+
monkeypatch.setattr(os, "replace", spy)
|
|
444
|
+
storage = JsonStorage(temp_db_path, disable_lock=True)
|
|
445
|
+
storage["key"] = "value"
|
|
446
|
+
storage.store()
|
|
447
|
+
|
|
448
|
+
assert len(calls) == 1
|
|
449
|
+
assert calls[0][1] == temp_db_path
|
|
450
|
+
assert os.path.dirname(calls[0][0]) == os.path.dirname(temp_db_path)
|
|
451
|
+
|
|
452
|
+
def test_store_keeps_file_permissions(self, temp_db_path):
|
|
453
|
+
"""Rewriting a file does not change its permissions."""
|
|
454
|
+
storage = JsonStorage(temp_db_path, disable_lock=True)
|
|
455
|
+
storage.store()
|
|
456
|
+
os.chmod(temp_db_path, 0o600)
|
|
457
|
+
|
|
458
|
+
storage["key"] = "value"
|
|
459
|
+
storage.store()
|
|
460
|
+
|
|
461
|
+
assert os.stat(temp_db_path).st_mode & 0o777 == 0o600
|
|
462
|
+
|
|
463
|
+
def test_stored_file_is_always_parseable(self, temp_db_path):
|
|
464
|
+
"""Repeated stores of growing data always leave valid json."""
|
|
465
|
+
storage = JsonStorage(temp_db_path, disable_lock=True)
|
|
466
|
+
for i in range(20):
|
|
467
|
+
storage[f"key{i}"] = "x" * (i * 100)
|
|
468
|
+
storage.store()
|
|
469
|
+
with open(temp_db_path, 'r') as f:
|
|
470
|
+
assert json.load(f) == dict(storage)
|
|
471
|
+
|
|
472
|
+
def test_symlinked_destination_stays_a_symlink(self, tmp_path):
|
|
473
|
+
"""A dotfile pointing at shared state must keep pointing at it.
|
|
474
|
+
|
|
475
|
+
``~/.config/hivemind/_identity.json -> /mnt/state/identity.json`` is
|
|
476
|
+
a normal setup. Replacing the link with a regular file freezes the
|
|
477
|
+
shared file and nothing says so.
|
|
478
|
+
"""
|
|
479
|
+
target = tmp_path / "shared.json"
|
|
480
|
+
target.write_text("{}")
|
|
481
|
+
link = tmp_path / "link.json"
|
|
482
|
+
link.symlink_to(target)
|
|
483
|
+
|
|
484
|
+
storage = JsonStorage(str(link), disable_lock=True)
|
|
485
|
+
storage["key"] = "value"
|
|
486
|
+
storage.store()
|
|
487
|
+
|
|
488
|
+
assert link.is_symlink()
|
|
489
|
+
assert json.loads(target.read_text()) == {"key": "value"}
|
|
490
|
+
|
|
491
|
+
@pytest.mark.skipif(os.name != "posix" or os.geteuid() != 0,
|
|
492
|
+
reason="only root can give a file away")
|
|
493
|
+
def test_root_store_keeps_the_owner_of_the_file(self, tmp_path):
|
|
494
|
+
"""`sudo hivemind-core add-client` must not hand the db to root.
|
|
495
|
+
|
|
496
|
+
os.replace keeps the temp file's owner, so without a chown the
|
|
497
|
+
unprivileged service that owns the file can no longer write it.
|
|
498
|
+
"""
|
|
499
|
+
import pwd
|
|
500
|
+
owner = pwd.getpwnam("nobody")
|
|
501
|
+
db_file = tmp_path / "clients.json"
|
|
502
|
+
db_file.write_text("{}")
|
|
503
|
+
os.chown(db_file, owner.pw_uid, owner.pw_gid)
|
|
504
|
+
|
|
505
|
+
storage = JsonStorage(str(db_file), disable_lock=True)
|
|
506
|
+
storage["key"] = "value"
|
|
507
|
+
storage.store()
|
|
508
|
+
|
|
509
|
+
stat = os.stat(db_file)
|
|
510
|
+
assert (stat.st_uid, stat.st_gid) == (owner.pw_uid, owner.pw_gid)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|