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.
Files changed (27) hide show
  1. {json_database-1.0.3a1 → json_database-1.0.4a2}/PKG-INFO +1 -1
  2. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/__init__.py +59 -3
  3. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/version.py +2 -2
  4. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/PKG-INFO +1 -1
  5. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_storage.py +118 -0
  6. {json_database-1.0.3a1 → json_database-1.0.4a2}/LICENSE +0 -0
  7. {json_database-1.0.3a1 → json_database-1.0.4a2}/README.md +0 -0
  8. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/crypto.py +0 -0
  9. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/exceptions.py +0 -0
  10. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/search.py +0 -0
  11. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/utils.py +0 -0
  12. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database/xdg_utils.py +0 -0
  13. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/SOURCES.txt +0 -0
  14. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/dependency_links.txt +0 -0
  15. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/requires.txt +0 -0
  16. {json_database-1.0.3a1 → json_database-1.0.4a2}/json_database.egg-info/top_level.txt +0 -0
  17. {json_database-1.0.3a1 → json_database-1.0.4a2}/pyproject.toml +0 -0
  18. {json_database-1.0.3a1 → json_database-1.0.4a2}/setup.cfg +0 -0
  19. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_crypto.py +0 -0
  20. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_database.py +0 -0
  21. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_e2e_user_database.py +0 -0
  22. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_encrypted_storage.py +0 -0
  23. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_exceptions.py +0 -0
  24. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_query.py +0 -0
  25. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_search.py +0 -0
  26. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_xdg.py +0 -0
  27. {json_database-1.0.3a1 → json_database-1.0.4a2}/test/test_xdg_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json_database
3
- Version: 1.0.3a1
3
+ Version: 1.0.4a2
4
4
  Summary: searchable json database with persistence
5
5
  Author-email: jarbasAI <jarbasai@mailfence.com>
6
6
  License: MIT
@@ -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
- with open(path, 'w', encoding="utf-8") as f:
139
- json.dump(self, f, indent=4, ensure_ascii=False)
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 = 3
5
- VERSION_ALPHA = 1
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 "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json_database
3
- Version: 1.0.3a1
3
+ Version: 1.0.4a2
4
4
  Summary: searchable json database with persistence
5
5
  Author-email: jarbasAI <jarbasai@mailfence.com>
6
6
  License: MIT
@@ -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