dol 0.3.76__tar.gz → 0.3.78__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.

Potentially problematic release.


This version of dol might be problematic. Click here for more details.

Files changed (101) hide show
  1. {dol-0.3.76 → dol-0.3.78}/.claude/skills/dol-dev-wrap-kvs/SKILL.md +4 -3
  2. {dol-0.3.76 → dol-0.3.78}/CLAUDE.md +1 -9
  3. {dol-0.3.76 → dol-0.3.78}/PKG-INFO +1 -1
  4. {dol-0.3.76 → dol-0.3.78}/dol/base.py +21 -4
  5. {dol-0.3.76 → dol-0.3.78}/dol/errors.py +33 -2
  6. {dol-0.3.76 → dol-0.3.78}/dol/filesys.py +54 -2
  7. {dol-0.3.76 → dol-0.3.78}/dol/misc.py +10 -25
  8. {dol-0.3.76 → dol-0.3.78}/dol/paths.py +15 -16
  9. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_filesys.py +136 -0
  10. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_paths.py +119 -0
  11. {dol-0.3.76 → dol-0.3.78}/dol/trans.py +11 -2
  12. {dol-0.3.76 → dol-0.3.78}/dol/util.py +3 -0
  13. {dol-0.3.76 → dol-0.3.78}/misc/docs/README.md +2 -8
  14. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_roadmap.md +2 -2
  15. dol-0.3.78/misc/fleet_ledger/README.md +72 -0
  16. dol-0.3.78/misc/fleet_ledger/__init__.py +24 -0
  17. dol-0.3.78/misc/fleet_ledger/__main__.py +152 -0
  18. dol-0.3.78/misc/fleet_ledger/astscan.py +535 -0
  19. dol-0.3.78/misc/fleet_ledger/baseline.py +333 -0
  20. dol-0.3.78/misc/fleet_ledger/fleet.py +106 -0
  21. dol-0.3.78/misc/fleet_ledger/pins.py +205 -0
  22. dol-0.3.78/misc/fleet_ledger/report.py +265 -0
  23. dol-0.3.78/misc/fleet_ledger/scan.py +286 -0
  24. dol-0.3.78/misc/fleet_ledger/store.py +220 -0
  25. dol-0.3.78/misc/fleet_ledger/surface.py +196 -0
  26. dol-0.3.78/misc/fleet_ledger/tests/__init__.py +0 -0
  27. dol-0.3.78/misc/fleet_ledger/tests/test_astscan.py +192 -0
  28. dol-0.3.78/misc/fleet_ledger/tests/test_ledger.py +242 -0
  29. {dol-0.3.76 → dol-0.3.78}/pyproject.toml +4 -3
  30. {dol-0.3.76 → dol-0.3.78}/.claude/commands/add-codec.md +0 -0
  31. {dol-0.3.76 → dol-0.3.78}/.claude/commands/explain-store.md +0 -0
  32. {dol-0.3.76 → dol-0.3.78}/.claude/commands/new-store.md +0 -0
  33. {dol-0.3.76 → dol-0.3.78}/.claude/rules/dol-conventions.md +0 -0
  34. {dol-0.3.76 → dol-0.3.78}/.claude/skills/dol-dev-portability/SKILL.md +0 -0
  35. {dol-0.3.76 → dol-0.3.78}/.claude/skills/dol-store-building/SKILL.md +0 -0
  36. {dol-0.3.76 → dol-0.3.78}/.gitattributes +0 -0
  37. {dol-0.3.76 → dol-0.3.78}/.github/workflows/ci.yml +0 -0
  38. {dol-0.3.76 → dol-0.3.78}/.gitignore +0 -0
  39. {dol-0.3.76 → dol-0.3.78}/LICENSE +0 -0
  40. {dol-0.3.76 → dol-0.3.78}/README.md +0 -0
  41. {dol-0.3.76 → dol-0.3.78}/dol/__init__.py +0 -0
  42. {dol-0.3.76 → dol-0.3.78}/dol/_interface_wrap.py +0 -0
  43. {dol-0.3.76 → dol-0.3.78}/dol/_paths_core.py +0 -0
  44. {dol-0.3.76 → dol-0.3.78}/dol/appendable.py +0 -0
  45. {dol-0.3.76 → dol-0.3.78}/dol/caching.py +0 -0
  46. {dol-0.3.76 → dol-0.3.78}/dol/content.py +0 -0
  47. {dol-0.3.76 → dol-0.3.78}/dol/dig.py +0 -0
  48. {dol-0.3.76 → dol-0.3.78}/dol/explicit.py +0 -0
  49. {dol-0.3.76 → dol-0.3.78}/dol/kv_codecs.py +0 -0
  50. {dol-0.3.76 → dol-0.3.78}/dol/mixins.py +0 -0
  51. {dol-0.3.76 → dol-0.3.78}/dol/naming.py +0 -0
  52. {dol-0.3.76 → dol-0.3.78}/dol/py.typed +0 -0
  53. {dol-0.3.76 → dol-0.3.78}/dol/recipes.py +0 -0
  54. {dol-0.3.76 → dol-0.3.78}/dol/scrap/__init__.py +0 -0
  55. {dol-0.3.76 → dol-0.3.78}/dol/scrap/new_store_wrap.py +0 -0
  56. {dol-0.3.76 → dol-0.3.78}/dol/scrap/store_factories.py +0 -0
  57. {dol-0.3.76 → dol-0.3.78}/dol/signatures.py +0 -0
  58. {dol-0.3.76 → dol-0.3.78}/dol/sources.py +0 -0
  59. {dol-0.3.76 → dol-0.3.78}/dol/tests/__init__.py +0 -0
  60. {dol-0.3.76 → dol-0.3.78}/dol/tests/base_test.py +0 -0
  61. {dol-0.3.76 → dol-0.3.78}/dol/tests/pickability_test.py +0 -0
  62. {dol-0.3.76 → dol-0.3.78}/dol/tests/scrap.py +0 -0
  63. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_appendable.py +0 -0
  64. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_caching.py +0 -0
  65. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_confirm_overwrite.py +0 -0
  66. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_content.py +0 -0
  67. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_dig.py +0 -0
  68. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_dol_tools.py +0 -0
  69. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_edge_cases.py +0 -0
  70. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_errors.py +0 -0
  71. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_fanout_stores.py +0 -0
  72. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_interface_wrap.py +0 -0
  73. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_kv_codecs.py +0 -0
  74. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_mk_dirs_if_missing.py +0 -0
  75. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_packaging_hygiene.py +0 -0
  76. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_path_writethrough.py +0 -0
  77. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_trans.py +0 -0
  78. {dol-0.3.76 → dol-0.3.78}/dol/tests/test_walk_permissions.py +0 -0
  79. {dol-0.3.76 → dol-0.3.78}/dol/tests/utils_for_tests.py +0 -0
  80. {dol-0.3.76 → dol-0.3.78}/dol/tools.py +0 -0
  81. {dol-0.3.76 → dol-0.3.78}/dol/trash.py +0 -0
  82. {dol-0.3.76 → dol-0.3.78}/dol/zipfiledol.py +0 -0
  83. {dol-0.3.76 → dol-0.3.78}/llms-full.txt +0 -0
  84. {dol-0.3.76 → dol-0.3.78}/llms.txt +0 -0
  85. {dol-0.3.76 → dol-0.3.78}/misc/TIPS.md +0 -0
  86. {dol-0.3.76 → dol-0.3.78}/misc/docs/CHANGELOG.md +0 -0
  87. {dol-0.3.76 → dol-0.3.78}/misc/docs/code-quality-improvements.md +0 -0
  88. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_architecture_map.md +0 -0
  89. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_content_metadata_bifurcation.md +0 -0
  90. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_design.md +0 -0
  91. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_flat_model.md +0 -0
  92. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issue10_design.md +0 -0
  93. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issue16_design.md +0 -0
  94. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issue18_design.md +0 -0
  95. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issue83_design.md +0 -0
  96. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issue86_design.md +0 -0
  97. {dol-0.3.76 → dol-0.3.78}/misc/docs/dol_issues_report.md +0 -0
  98. {dol-0.3.76 → dol-0.3.78}/misc/docs/frontend_dol_ideas.md +0 -0
  99. {dol-0.3.76 → dol-0.3.78}/misc/docs/general_design.md +0 -0
  100. {dol-0.3.76 → dol-0.3.78}/misc/docs/generate_llms_txt_instruction.md +0 -0
  101. {dol-0.3.76 → dol-0.3.78}/misc/docs/issues_and_discussions.md +0 -0
@@ -136,12 +136,13 @@ wrappers, and test all four forms (see `tests/test_trans.py::test_redirect_getat
136
136
  dol is editably installed across the local ecosystem, so **dependents already import your
137
137
  working-tree dol** — no reinstall needed. Before landing a wrap_kvs/Store.wrap change:
138
138
  1. Measure blast radius statically: AST-scan dependents for transforms passed to
139
- wrap_kvs-family whose first param ∈ self_names (see `misc/data/` scan scripts;
139
+ wrap_kvs-family whose first param ∈ self_names (the `misc/fleet_ledger` census lists every call site with its kwargs and their kinds;
140
140
  the reusable pattern found 0 breaking + 12 safe self-convention sites).
141
141
  2. Run the dependents test-gate **baseline vs modified**: run each key dependent's suite
142
142
  with your change, then `git stash` your dol edits, run again (baseline), `git stash pop`;
143
- a `PASS→FAIL` delta is a real regression (env `FAIL→FAIL` is not). Helpers and the
144
- gate order live in `misc/data/` (gitignored). Prioritize `config2py → graze → ju →
143
+ a `PASS→FAIL` delta is a real regression (env `FAIL→FAIL` is not). The ledger does this
144
+ per test id: `python -m misc.fleet_ledger baseline` before and after, then `diff`
145
+ (see `misc/fleet_ledger/README.md`); `priv test-dependents dol` is the quick gate. Prioritize `config2py → graze → ju →
145
146
  tabled → oa` (highest transitive fan-out) plus the self-convention users
146
147
  (`py2store, sqldol, tec, unbox, mongodol`).
147
148
 
@@ -227,15 +227,7 @@ and the full doc index with status labels. The table below is the quick version.
227
227
  | [dol_content_metadata_bifurcation.md](misc/docs/dol_content_metadata_bifurcation.md) | The content/metadata split-store problem (feeds issue #80) |
228
228
  | [code-quality-improvements.md](misc/docs/code-quality-improvements.md) | Tech-debt tracker: dead code, coverage gaps (feeds issue #94) |
229
229
 
230
- > The **fleet usage ledger** ([#91](https://github.com/i2mint/dol/issues/91)) lives
231
- > in gitignored `misc/data/` — start at its [README](misc/data/README.md).
232
- > `python -m fleet_ledger scan` records one immutable, timestamped scan: dol's **89
233
- > direct dependents** pinned at full 40-char commits, their imports, AST-verified
234
- > subclasses and call-site kwargs (all `file:line`), a generated zero-usage report,
235
- > and the pre-PR test-gate order. `baseline` captures every dependent's suite result
236
- > so a candidate's damage can be told apart from pre-existing red; `pin restore`
237
- > freezes the fleet at a scan and `pin undo` releases it. Scans accumulate — nothing
238
- > is overwritten. Figures quoted in these docs are from the 2026-08-21 scan.
230
+ > The **fleet usage ledger** ([#91](https://github.com/i2mint/dol/issues/91)) is committed code in `misc/fleet_ledger/` — start at its [README](misc/fleet_ledger/README.md). Its *output* is never committed (it is derived from the fleet, some of it private): it lives in `~/.local/share/dol/fleet_ledger/dol/`. `python -m misc.fleet_ledger scan` records one immutable, timestamped scan: every direct dependent (from priv's `dep_graph`) pinned at its full commit, its imports, AST-verified subclasses and call-site kwargs (all `file:line`), and a generated zero-usage report (`zero-usage`). `baseline` captures every dependent's suite result with its failing test ids, so `diff` tells a candidate's damage apart from pre-existing red; `pin restore` freezes the fleet at a scan and `pin undo` releases it. Scans accumulate — nothing is overwritten. Figures quoted in these docs are from the 2026-08-21 scan; rescan for current ones.
239
231
 
240
232
  ---
241
233
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dol
3
- Version: 0.3.76
3
+ Version: 0.3.78
4
4
  Summary: Base builtin tools make and transform data object layers (dols).
5
5
  Project-URL: Homepage, https://github.com/i2mint/dol
6
6
  License: MIT
@@ -51,7 +51,7 @@ from collections.abc import (
51
51
  from typing import Any, Tuple, Union, Optional
52
52
  from collections.abc import Iterable, Callable
53
53
 
54
- from dol.errors import OutOfScopeKeyError
54
+ from dol.errors import OutOfScopeKeyError, may_raise_out_of_scope
55
55
  from dol.util import (
56
56
  wraps,
57
57
  _disabled_clear_method,
@@ -814,8 +814,8 @@ class Store(KvPersister):
814
814
  lacks the prefix/suffix the layer strips) has no outer key, so it is skipped:
815
815
  the store is a *view* of the in-scope ids. Reading or writing such an id through
816
816
  an outer key is still impossible, and calling ``_key_of_id`` directly still
817
- raises (i2mint/dol#82, #114). Note that ``len`` counts inner ids, so it may
818
- exceed the number of keys iterated.
817
+ raises (i2mint/dol#82, #114). ``len`` and ``bool`` agree with this iteration
818
+ (see ``__len__``).
819
819
  """
820
820
  key_of_id = self._key_of_id
821
821
  for _id in self.store:
@@ -826,7 +826,24 @@ class Store(KvPersister):
826
826
  yield k
827
827
 
828
828
  def __len__(self) -> int:
829
- return len(self.store)
829
+ """The inner store's ``len``, or, when the key layer is scoped (its
830
+ ``_key_of_id`` is marked with ``raises_out_of_scope``), the number of in-scope
831
+ ids, which takes a scan (i2mint/dol#123).
832
+
833
+ The inner ``len`` is always asked first, so an inner store that refuses ``len``
834
+ (e.g. a remote listing, to spare ``list()`` a second scan) still refuses it
835
+ here, and an empty one costs no scan. Otherwise note that ``list(store)`` of a
836
+ scoped layer scans twice: once to size the list, once to fill it.
837
+ """
838
+ n = len(self.store)
839
+ if n and may_raise_out_of_scope(self._key_of_id):
840
+ return sum(1 for _ in self)
841
+ return n
842
+
843
+ def __bool__(self) -> bool:
844
+ if may_raise_out_of_scope(self._key_of_id):
845
+ return any(True for _ in self) # stops at the first in-scope id
846
+ return len(self) > 0
830
847
 
831
848
  def __contains__(self, k) -> bool:
832
849
  return self._id_of_key(k) in self.store
@@ -145,11 +145,42 @@ class OutOfScopeKeyError(KeyError, ValueError):
145
145
  __str__ = Exception.__str__ # plain message, not KeyError's repr of it
146
146
 
147
147
 
148
+ _RAISES_OUT_OF_SCOPE = "_dol_raises_out_of_scope"
149
+
150
+
151
+ def raises_out_of_scope(func):
152
+ """Mark ``func``, a ``key_of_id``, as one that may raise ``OutOfScopeKeyError``.
153
+
154
+ A ``Store`` layer whose ``_key_of_id`` is so marked is a *view* of the in-scope
155
+ ids, so its ``len`` counts them (a scan) instead of returning ``len`` of the inner
156
+ store, which would overcount (i2mint/dol#123). Unmarked key mappings keep the inner
157
+ store's (often O(1)) ``len``. Returns ``func``, so it works as a decorator.
158
+
159
+ ``wrap_kvs`` (and so the codecs), ``Pipe`` and subclasses made by class wrapping
160
+ carry the mark over; a ``_key_of_id`` you override by hand (even one calling
161
+ ``super()``) does not, so mark it too if it can raise.
162
+
163
+ >>> @raises_out_of_scope
164
+ ... def key_of_id(_id):
165
+ ... return _id
166
+ >>> may_raise_out_of_scope(key_of_id), may_raise_out_of_scope(str.upper)
167
+ (True, False)
168
+ """
169
+ setattr(func, _RAISES_OUT_OF_SCOPE, True)
170
+ return func
171
+
172
+
173
+ def may_raise_out_of_scope(func) -> bool:
174
+ """Whether ``func`` was marked with :func:`raises_out_of_scope` (a bound method
175
+ reads the mark off its function)."""
176
+ return getattr(func, _RAISES_OUT_OF_SCOPE, False) is True
177
+
178
+
148
179
  def out_of_scope_prefix_error(_id, prefix):
149
180
  """The ``OutOfScopeKeyError`` for an ``_id`` that doesn't start with ``prefix``."""
150
181
  return OutOfScopeKeyError(
151
182
  f"{_id!r} does not start with the prefix {prefix!r}, so it is outside this "
152
- "store's scope, so iterating the store skips it and it can't be read or "
183
+ "store's scope: iterating the store skips it, and it can't be read or "
153
184
  "written through an outer key (see i2mint/dol#82)."
154
185
  )
155
186
 
@@ -158,7 +189,7 @@ def out_of_scope_suffix_error(_id, suffix):
158
189
  """The ``OutOfScopeKeyError`` for an ``_id`` that doesn't end with ``suffix``."""
159
190
  return OutOfScopeKeyError(
160
191
  f"{_id!r} does not end with the suffix {suffix!r}, so it is outside this "
161
- "store's scope, so iterating the store skips it and it can't be read or "
192
+ "store's scope: iterating the store skips it, and it can't be read or "
162
193
  "written through an outer key (see i2mint/dol#114)."
163
194
  )
164
195
 
@@ -448,6 +448,46 @@ def path_is_under(path: str, rootdir: str) -> bool:
448
448
  return False
449
449
 
450
450
 
451
+ def dotdot_resolves_lexically(path: str, *, base: str = "") -> bool:
452
+ """Whether every ``..`` in ``path`` undoes the segment before it, as
453
+ ``os.path.normpath`` assumes.
454
+
455
+ It doesn't when a symlink comes before a ``..``: the OS then climbs out of the
456
+ link's target, so ``root/lnk/../x`` with ``root/lnk -> /elsewhere/deep`` addresses
457
+ ``/elsewhere/x``, not ``root/x`` (i2mint/dol#123). Paths without a ``..`` segment
458
+ are not touched (no file-system access); otherwise the part up to the last ``..``
459
+ is resolved both ways and compared.
460
+
461
+ With ``base`` (a string prefix of ``path``, e.g. a store's ``rootdir``), only the
462
+ ``..`` segments after it count: ``base`` itself is taken as the OS resolves it,
463
+ whatever ``..`` it holds. Conservative by design: a ``..`` after a symlink is
464
+ refused even when the link points back inside ``base``.
465
+
466
+ >>> dotdot_resolves_lexically('/data/root/a/b.txt')
467
+ True
468
+ >>> dotdot_resolves_lexically('/no/such/dir/../file') # nothing to follow: lexical
469
+ True
470
+ """
471
+ if base and path.startswith(base):
472
+ base, path = path[: len(base)], path[len(base) :]
473
+ else:
474
+ base = ""
475
+ if os.pardir not in path: # fast path: the common case, e.g. every listed key
476
+ return True
477
+ if os.altsep:
478
+ path = path.replace(os.altsep, os.sep)
479
+ segments = path.split(os.sep)
480
+ if os.pardir not in segments:
481
+ return True
482
+ last_pardir = len(segments) - 1 - segments[::-1].index(os.pardir)
483
+ head = os.sep.join(segments[: last_pardir + 1])
484
+ try:
485
+ physical = os.path.realpath(base + head)
486
+ return physical == os.path.realpath(base + os.path.normpath(head))
487
+ except (OSError, ValueError): # unresolvable: don't vouch for it
488
+ return False
489
+
490
+
451
491
  class FileSysCollection(Collection):
452
492
  # rootdir = None # mentioning here so that the attribute is seen as an attribute before instantiation.
453
493
 
@@ -503,8 +543,20 @@ class FileSysCollection(Collection):
503
543
 
504
544
  def is_in_root(self, k):
505
545
  """Whether ``k`` stays under ``rootdir`` (always true with
506
- ``allow_escape=True`` or an empty ``rootdir``)."""
507
- return self.allow_escape or not self.rootdir or path_is_under(k, self.rootdir)
546
+ ``allow_escape=True`` or an empty ``rootdir``).
547
+
548
+ The containment test is lexical (:func:`path_is_under`), so a key whose ``..``
549
+ follows a symlink -- where the OS climbs out of the link's *target*, not back
550
+ to the link's directory -- is refused too (i2mint/dol#123).
551
+ """
552
+ return (
553
+ self.allow_escape
554
+ or not self.rootdir
555
+ or (
556
+ path_is_under(k, self.rootdir)
557
+ and dotdot_resolves_lexically(k, base=self.rootdir)
558
+ )
559
+ )
508
560
 
509
561
  def validate_in_root(self, k):
510
562
  """Raise ``KeyValidationError`` if ``k`` leaves ``rootdir`` (i2mint/dol#107).
@@ -117,11 +117,8 @@ class MiscReaderMixin:
117
117
  Warning: If used as a subclass, this mixin should (in general) be placed before the store
118
118
 
119
119
  Security:
120
- ``.pkl``/``.pickle`` keys are unpickled by default (arbitrary code execution):
121
- never use this over untrusted content or with request-derived keys. For
122
- ``get_obj`` and the getters this includes URLs: ``get_obj('https://.../x.pkl')``
123
- downloads and unpickles. Give it an ``incoming_val_trans_for_key`` without the
124
- pickle extensions and such keys come back as raw bytes instead (i2mint/dol#113).
120
+ ``.pkl``/``.pickle`` keys are unpickled by default, which runs code from the
121
+ data: see the warning in the ``dol.misc`` module docstring (i2mint/dol#113).
125
122
 
126
123
 
127
124
  >>> # make a reader that will wrap a dict
@@ -253,11 +250,8 @@ def get_obj(
253
250
  """A quick way to get an object, with default... everything (but the key, you know, a clue of what you want)
254
251
 
255
252
  Security:
256
- ``.pkl``/``.pickle`` keys are unpickled by default (arbitrary code execution):
257
- never use this over untrusted content or with request-derived keys. For
258
- ``get_obj`` and the getters this includes URLs: ``get_obj('https://.../x.pkl')``
259
- downloads and unpickles. Give it an ``incoming_val_trans_for_key`` without the
260
- pickle extensions and such keys come back as raw bytes instead (i2mint/dol#113).
253
+ ``.pkl``/``.pickle`` keys are unpickled by default, which runs code from the
254
+ data: see the warning in the ``dol.misc`` module docstring (i2mint/dol#113).
261
255
  """
262
256
  if k.startswith("http://") or k.startswith("https://"):
263
257
  v = url_to_bytes(k)
@@ -285,11 +279,8 @@ class MiscGetter:
285
279
  according to a property of the key (default: file extension).
286
280
 
287
281
  Security:
288
- ``.pkl``/``.pickle`` keys are unpickled by default (arbitrary code execution):
289
- never use this over untrusted content or with request-derived keys. For
290
- ``get_obj`` and the getters this includes URLs: ``get_obj('https://.../x.pkl')``
291
- downloads and unpickles. Give it an ``incoming_val_trans_for_key`` without the
292
- pickle extensions and such keys come back as raw bytes instead (i2mint/dol#113).
282
+ ``.pkl``/``.pickle`` keys are unpickled by default, which runs code from the
283
+ data: see the warning in the ``dol.misc`` module docstring (i2mint/dol#113).
293
284
 
294
285
  >>> from dol.misc import get_obj, misc_objs_get
295
286
  >>> import os
@@ -356,11 +347,8 @@ class MiscStoreMixin(MiscReaderMixin):
356
347
  If used as a subclass, this mixin should (in general) be placed before the store
357
348
 
358
349
  Security:
359
- ``.pkl``/``.pickle`` keys are unpickled by default (arbitrary code execution):
360
- never use this over untrusted content or with request-derived keys. For
361
- ``get_obj`` and the getters this includes URLs: ``get_obj('https://.../x.pkl')``
362
- downloads and unpickles. Give it an ``incoming_val_trans_for_key`` without the
363
- pickle extensions and such keys come back as raw bytes instead (i2mint/dol#113).
350
+ ``.pkl``/``.pickle`` keys are unpickled by default, which runs code from the
351
+ data: see the warning in the ``dol.misc`` module docstring (i2mint/dol#113).
364
352
 
365
353
  See also:
366
354
  preset and postget args from wrap_kvs decorator from dol.trans.
@@ -465,11 +453,8 @@ class MiscGetterAndSetter(MiscGetter):
465
453
  according to a property of the key (default: file extension).
466
454
 
467
455
  Security:
468
- ``.pkl``/``.pickle`` keys are unpickled by default (arbitrary code execution):
469
- never use this over untrusted content or with request-derived keys. For
470
- ``get_obj`` and the getters this includes URLs: ``get_obj('https://.../x.pkl')``
471
- downloads and unpickles. Give it an ``incoming_val_trans_for_key`` without the
472
- pickle extensions and such keys come back as raw bytes instead (i2mint/dol#113).
456
+ ``.pkl``/``.pickle`` keys are unpickled by default, which runs code from the
457
+ data: see the warning in the ``dol.misc`` module docstring (i2mint/dol#113).
473
458
 
474
459
  >>> from dol.misc import set_obj, misc_objs # the function and the object
475
460
  >>> import json
@@ -36,7 +36,7 @@ from operator import getitem
36
36
  import os
37
37
 
38
38
  from dol.base import Store
39
- from dol.util import lazyprop, add_as_attribute_of, max_common_prefix
39
+ from dol.util import add_as_attribute_of, max_common_prefix
40
40
  from dol.trans import (
41
41
  store_decorator,
42
42
  kv_wrap,
@@ -46,7 +46,7 @@ from dol.trans import (
46
46
  add_missing_key_handling,
47
47
  )
48
48
  from dol.dig import recursive_get_attr
49
- from dol.errors import out_of_scope_prefix_error
49
+ from dol.errors import out_of_scope_prefix_error, raises_out_of_scope
50
50
  from dol._paths_core import (
51
51
  PathContext,
52
52
  PathCreationError,
@@ -1005,6 +1005,14 @@ class KeyPath:
1005
1005
  # ------------------------------------------------------------------------------
1006
1006
 
1007
1007
 
1008
+ def _strip_prefix(_id, prefix):
1009
+ """``_id`` without ``prefix``; ``OutOfScopeKeyError`` if it doesn't start with it
1010
+ (slicing anyway would yield a key addressing a different object, i2mint/dol#82)."""
1011
+ if prefix and _id[: len(prefix)] != prefix:
1012
+ raise out_of_scope_prefix_error(_id, prefix)
1013
+ return _id[len(prefix) :]
1014
+
1015
+
1008
1016
  class PrefixRelativizationMixin:
1009
1017
  """
1010
1018
  Mixin that adds a intercepts the _id_of_key an _key_of_id methods, transforming absolute keys to relative ones.
@@ -1048,20 +1056,13 @@ class PrefixRelativizationMixin:
1048
1056
 
1049
1057
  _prefix_attr_name = "_prefix"
1050
1058
 
1051
- @lazyprop
1052
- def _prefix_length(self):
1053
- return len(getattr(self, self._prefix_attr_name))
1054
-
1055
1059
  def _id_of_key(self, k):
1056
1060
  return getattr(self, self._prefix_attr_name) + k
1057
1061
 
1062
+ @raises_out_of_scope
1058
1063
  def _key_of_id(self, _id):
1059
- prefix = getattr(self, self._prefix_attr_name)
1060
- if prefix and _id[: len(prefix)] != prefix:
1061
- raise out_of_scope_prefix_error(_id, prefix)
1062
- # slice by the live prefix's length: the cached ``_prefix_length`` goes stale
1063
- # if the prefix is reassigned after first use
1064
- return _id[len(prefix) :]
1064
+ # the live prefix, not a cached length: it may be reassigned after first use
1065
+ return _strip_prefix(_id, getattr(self, self._prefix_attr_name))
1065
1066
 
1066
1067
 
1067
1068
  class PrefixRelativization(PrefixRelativizationMixin):
@@ -1259,15 +1260,13 @@ class RelativePathKeyMapper:
1259
1260
 
1260
1261
  def __init__(self, prefix):
1261
1262
  self._prefix = prefix
1262
- self._prefix_length = len(self._prefix)
1263
1263
 
1264
1264
  def _id_of_key(self, k):
1265
1265
  return self._prefix + k
1266
1266
 
1267
+ @raises_out_of_scope
1267
1268
  def _key_of_id(self, _id):
1268
- if self._prefix and _id[: self._prefix_length] != self._prefix:
1269
- raise out_of_scope_prefix_error(_id, self._prefix)
1270
- return _id[self._prefix_length :]
1269
+ return _strip_prefix(_id, self._prefix)
1271
1270
 
1272
1271
 
1273
1272
  @store_decorator
@@ -373,3 +373,139 @@ def test_rootdir_spellings_still_list_their_keys(tmp_path, monkeypatch):
373
373
  assert list(Files("/" + str(root))) == [key] # '//...' rootdir
374
374
  monkeypatch.chdir(root)
375
375
  assert list(Files(os.path.join("a", ".."))) == [key] # rootdir normalising to '.'
376
+
377
+
378
+ # --- sprint review (i2mint/dol#123): coverage of the #107 containment surface --------
379
+
380
+
381
+ def test_path_is_under_mixed_absolute_and_relative_is_false():
382
+ from dol.filesys import path_is_under
383
+
384
+ # commonpath raises ValueError on a mix of absolute and relative paths
385
+ assert path_is_under(os.path.abspath("x"), "rel/root") is False
386
+ assert path_is_under("rel/root/x", os.path.abspath("root")) is False
387
+
388
+
389
+ def test_zip_readers_read_and_write_keys_inside_rootdir(tmp_path):
390
+ import zipfile
391
+ from dol.zipfiledol import ZipFilesReader, ZipFilesReaderAndBytesWriter
392
+
393
+ if os.name == "nt": # ZipFilesReader's default subpath regex is POSIX-only
394
+ return
395
+ root = tmp_path / "root"
396
+ root.mkdir()
397
+ zip_path = str(root / "in.zip")
398
+ with zipfile.ZipFile(zip_path, "w") as zf:
399
+ zf.writestr("member.txt", b"hello")
400
+ reader = ZipFilesReader(str(root))
401
+ assert reader[zip_path]["member.txt"] == b"hello"
402
+ with pytest.raises(KeyError): # inside the root, but no such file
403
+ reader[str(root / "missing.zip")]
404
+ writer = ZipFilesReaderAndBytesWriter(str(root))
405
+ copy_path = str(root / "copy.zip")
406
+ writer[copy_path] = (root / "in.zip").read_bytes()
407
+ assert writer[copy_path]["member.txt"] == b"hello"
408
+
409
+
410
+ def test_make_missing_dirs_mixin_reraises_out_of_root_keys(tmp_path):
411
+ from dol.filesys import FileBytesPersister, MakeMissingDirsStoreMixin
412
+
413
+ class S(MakeMissingDirsStoreMixin, FileBytesPersister):
414
+ pass
415
+
416
+ root = _sandbox(tmp_path)
417
+ s = S(str(root))
418
+ with pytest.raises(KeyError):
419
+ s[str(root) + "/../escaped/x.txt"] = b"pwned"
420
+ assert not (tmp_path / "escaped").exists()
421
+
422
+
423
+ # POSIX follows a symlink before applying the next '..'; Windows normalizes '..'
424
+ # lexically first, so there 'lnk/../x' IS 'root/x' and nothing escapes to refuse.
425
+ _DOTDOT_FOLLOWS_SYMLINKS = os.name != "nt"
426
+
427
+
428
+ def _symlink_sandbox(tmp_path):
429
+ """``root/lnk`` -> ``outside/deep``; ``outside/secret.txt`` is outside the root."""
430
+ root = _sandbox(tmp_path)
431
+ deep = tmp_path / "outside" / "deep"
432
+ deep.mkdir(parents=True)
433
+ (tmp_path / "outside" / "secret.txt").write_bytes(b"secret")
434
+ try:
435
+ os.symlink(str(deep), str(root / "lnk"), target_is_directory=True)
436
+ except (OSError, NotImplementedError): # e.g. Windows without the privilege
437
+ pytest.skip("cannot create symlinks here")
438
+ return root
439
+
440
+
441
+ def test_files_symlink_then_dotdot_does_not_escape_rootdir(tmp_path):
442
+ from dol.filesys import Files
443
+
444
+ root = _symlink_sandbox(tmp_path)
445
+ s = Files(str(root))
446
+ escaping_key = os.path.join("lnk", "..", "secret.txt")
447
+ assert escaping_key not in s
448
+ assert s.get(escaping_key) is None
449
+ pwn_key = os.path.join("lnk", "..", "pwned.txt")
450
+ if _DOTDOT_FOLLOWS_SYMLINKS:
451
+ with pytest.raises(KeyError):
452
+ s[pwn_key] = b"x"
453
+ else:
454
+ s[pwn_key] = b"x"
455
+ assert (root / "pwned.txt").read_bytes() == b"x" # contained in the root
456
+ assert not (tmp_path / "outside" / "pwned.txt").exists()
457
+
458
+
459
+ def test_symlinked_subdir_of_rootdir_stays_addressable(tmp_path):
460
+ # the design #107 kept: a symlink inside the root is followed, and a '..' that
461
+ # does not come after a symlink still resolves lexically
462
+ from dol.filesys import Files
463
+
464
+ root = _symlink_sandbox(tmp_path)
465
+ s = Files(str(root))
466
+ s[os.path.join("lnk", "f.txt")] = b"via link"
467
+ assert (tmp_path / "outside" / "deep" / "f.txt").read_bytes() == b"via link"
468
+ assert s[os.path.join("lnk", "f.txt")] == b"via link"
469
+ assert s[os.path.join("a", "..", "lnk", "f.txt")] == b"via link"
470
+ assert s[os.path.join("a", "..", "a", "in.txt")] == b"inside"
471
+
472
+
473
+ def test_symlink_then_dotdot_is_refused_by_every_containment_user(tmp_path):
474
+ from dol.filesys import DirReader, Files, mk_dirs_if_missing
475
+
476
+ root = _symlink_sandbox(tmp_path)
477
+ if not _DOTDOT_FOLLOWS_SYMLINKS:
478
+ pytest.skip("'..' never follows a symlink here: nothing escapes to refuse")
479
+ (tmp_path / "outside" / "sib").mkdir()
480
+ with pytest.raises(KeyError):
481
+ DirReader(str(root))[os.path.join(str(root), "lnk", "..", "sib")]
482
+ s = mk_dirs_if_missing(Files(str(root)))
483
+ with pytest.raises(KeyError):
484
+ s[os.path.join("lnk", "..", "made", "x.txt")] = b"pwned"
485
+ assert not (tmp_path / "outside" / "made").exists()
486
+ # allow_escape=True is the explicit opt-out
487
+ assert Files(str(root), allow_escape=True)[
488
+ os.path.join("lnk", "..", "secret.txt")
489
+ ] == b"secret"
490
+
491
+
492
+ def test_rootdir_with_dotdot_after_a_symlink_lists_and_reads(tmp_path):
493
+ # e.g. join(dirname(__file__), '..', 'data') in a symlink-installed package: the
494
+ # rootdir's own '..' is the OS's business, only the key's part beyond it is checked
495
+ from dol.filesys import Files
496
+
497
+ src = tmp_path / "src"
498
+ (src / "pkg").mkdir(parents=True)
499
+ (src / "data" / "sub").mkdir(parents=True)
500
+ (src / "data" / "a.txt").write_bytes(b"A")
501
+ site = tmp_path / "site"
502
+ site.mkdir()
503
+ try:
504
+ os.symlink(str(src / "pkg"), str(site / "pkg"), target_is_directory=True)
505
+ except (OSError, NotImplementedError):
506
+ pytest.skip("cannot create symlinks here")
507
+ s = Files(os.path.join(str(site), "pkg", "..", "data"))
508
+ if os.name != "nt": # Windows resolves '..' lexically: site/data doesn't exist
509
+ assert list(s) == ["a.txt"]
510
+ assert s["a.txt"] == b"A"
511
+ assert s[os.path.join("sub", "..", "a.txt")] == b"A"
@@ -233,3 +233,122 @@ def test_head_skips_out_of_scope_ids():
233
233
 
234
234
  s = KeyCodecs.prefixed("p/")({"stray": 0, "p/a": 1})
235
235
  assert s.head() == ("a", 1)
236
+
237
+
238
+ # --- sprint review (i2mint/dol#123): len/bool vs the in-scope iteration of #119 -------
239
+
240
+
241
+ @pytest.mark.parametrize("name, store", list(_prefix_stores()))
242
+ def test_len_counts_only_in_scope_ids(name, store):
243
+ assert len(store) == len(list(store)) == 2
244
+
245
+
246
+ def test_store_of_only_out_of_scope_ids_is_falsy():
247
+ from dol import KeyCodecs
248
+
249
+ s = KeyCodecs.prefixed("p/")({"stray": 0})
250
+ assert list(s) == []
251
+ assert not s
252
+
253
+
254
+ def test_len_of_unscoped_store_is_the_inner_len():
255
+ from dol import wrap_kvs
256
+
257
+ s = wrap_kvs({"a": 1, "b": 2}, key_of_id=str.upper, id_of_key=str.lower)
258
+ assert len(s) == 2 and bool(s)
259
+ assert len(wrap_kvs({}, key_of_id=str.upper)) == 0
260
+
261
+
262
+ def test_head_falls_back_to_inner_ids_when_getitem_fails():
263
+ from dol import wrap_kvs
264
+
265
+ # an asymmetric key mapping: iterated keys can't be read back
266
+ s = wrap_kvs({"p/stray": 0, "p/a": 1}, key_of_id=lambda _id: _id.upper())
267
+ with pytest.warns(UserWarning, match="Couldn't get data for the key"):
268
+ assert s.head() == ("P/STRAY", 0)
269
+
270
+
271
+ def test_composed_and_stacked_scoped_layers_count_in_scope_ids():
272
+ from dol import KeyCodecs, wrap_kvs
273
+
274
+ base = {"a/x.txt": 1, "a/y.json": 2, "b/z.txt": 3}
275
+ composed = (KeyCodecs.prefixed("a/") + KeyCodecs.suffixed(".txt"))(dict(base))
276
+ assert list(composed) == ["x"] and len(composed) == 1
277
+ # an unscoped layer over a scoped one gets its len from the scoped inner layer
278
+ upper = wrap_kvs(KeyCodecs.prefixed("a/")(dict(base)), key_of_id=str.upper)
279
+ assert sorted(upper) == ["X.TXT", "Y.JSON"] and len(upper) == 2
280
+
281
+
282
+ def test_subclass_of_scoped_class_stays_scoped():
283
+ from dol import KeyCodecs, wrap_kvs
284
+
285
+ Scoped = KeyCodecs.prefixed("p/")(dict)
286
+ Sub = wrap_kvs(Scoped, key_of_id=str.upper, id_of_key=str.lower)
287
+ s = Sub({"stray": 0, "p/a": 1})
288
+ assert list(s) == ["A"] and len(s) == 1 and s["A"] == 1
289
+
290
+
291
+ def test_empty_affix_codec_keeps_the_inner_len():
292
+ from dol import KeyCodecs
293
+ from dol.errors import may_raise_out_of_scope
294
+
295
+ codec = KeyCodecs.affixed()
296
+ assert not may_raise_out_of_scope(codec.decoder)
297
+ assert len(codec({"a": 1})) == 1
298
+
299
+
300
+ def test_scoped_len_keeps_the_inner_stores_refusal_and_empty_shortcut():
301
+ from collections.abc import Mapping
302
+ from dol import KeyCodecs
303
+
304
+ class NoLenListing(Mapping):
305
+ """A remote-like store refusing len, so list() lists it only once."""
306
+
307
+ def __init__(self, d):
308
+ self.d, self.listings = d, 0
309
+
310
+ def __getitem__(self, k):
311
+ return self.d[k]
312
+
313
+ def __iter__(self):
314
+ self.listings += 1
315
+ return iter(self.d)
316
+
317
+ def __len__(self):
318
+ raise TypeError("no len: it would take a full listing")
319
+
320
+ inner = NoLenListing({"p/a": 1, "q/b": 2})
321
+ s = KeyCodecs.prefixed("p/")(inner)
322
+ assert list(s) == ["a"] and inner.listings == 1
323
+ with pytest.raises(TypeError):
324
+ len(s)
325
+
326
+ class CountingDict(dict):
327
+ def __iter__(self):
328
+ raise AssertionError("an empty inner store needs no scan")
329
+
330
+ assert len(KeyCodecs.prefixed("p/")(CountingDict())) == 0
331
+
332
+
333
+ def test_first_arg_is_mapping_keeps_the_scope_mark():
334
+ from dol import wrap_kvs, FirstArgIsMapping
335
+ from dol.errors import raises_out_of_scope, OutOfScopeKeyError
336
+
337
+ @raises_out_of_scope
338
+ def key_of_id(self, _id):
339
+ if not _id.startswith("p/"):
340
+ raise OutOfScopeKeyError(_id)
341
+ return _id[2:]
342
+
343
+ s = wrap_kvs({"p/a": 1, "q/b": 2}, key_of_id=FirstArgIsMapping(key_of_id))
344
+ assert list(s) == ["a"] and len(s) == 1
345
+
346
+
347
+ def test_may_raise_out_of_scope_is_a_real_bool():
348
+ from dol.errors import may_raise_out_of_scope
349
+
350
+ class AnyAttr:
351
+ def __getattr__(self, name):
352
+ return "truthy"
353
+
354
+ assert may_raise_out_of_scope(AnyAttr()) is False
@@ -41,6 +41,8 @@ from dol.errors import (
41
41
  SetattrNotAllowed,
42
42
  out_of_scope_prefix_error,
43
43
  out_of_scope_suffix_error,
44
+ raises_out_of_scope,
45
+ may_raise_out_of_scope,
44
46
  )
45
47
  from dol.base import Store, KvReader, AttrNames, kv_walk
46
48
  from dol._paths_core import (
@@ -1966,7 +1968,10 @@ def _wrap_outcoming(
1966
1968
  [7, 14]
1967
1969
  """
1968
1970
  if trans_func is not None:
1971
+ marked = trans_func
1969
1972
  trans_func, wants_self = _resolve_self_convention(trans_func)
1973
+ # read the mark off the transform itself, not a FirstArgIsMapping around it
1974
+ is_scoped = may_raise_out_of_scope(marked) or may_raise_out_of_scope(trans_func)
1970
1975
  wrapped_func = getattr(store_cls, wrapped_method)
1971
1976
 
1972
1977
  if not wants_self:
@@ -1993,6 +1998,8 @@ def _wrap_outcoming(
1993
1998
  self, getattr(super(store_cls, self), wrapped_method)(x)
1994
1999
  )
1995
2000
 
2001
+ if is_scoped: # (``wraps`` already carried over a scoped wrapped_func's mark)
2002
+ raises_out_of_scope(new_method)
1996
2003
  setattr(store_cls, wrapped_method, new_method)
1997
2004
 
1998
2005
 
@@ -3862,9 +3869,11 @@ def affix_key_codec(prefix: str = "", suffix: str = ""):
3862
3869
  >>> codec.decoder('/folder/name.txt')
3863
3870
  'name'
3864
3871
  """
3872
+ decoder = partial(_affix_decoder, prefix=prefix, suffix=suffix)
3873
+ if prefix or suffix:
3874
+ raises_out_of_scope(decoder)
3865
3875
  return KeyCodec(
3866
- encoder=partial(_affix_encoder, prefix=prefix, suffix=suffix),
3867
- decoder=partial(_affix_decoder, prefix=prefix, suffix=suffix),
3876
+ encoder=partial(_affix_encoder, prefix=prefix, suffix=suffix), decoder=decoder
3868
3877
  )
3869
3878
 
3870
3879