aptriever 0.1.0__py3-none-any.whl

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.
aptriever/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
aptriever/addresses.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import socket
5
+ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address
6
+ from pathlib import Path
7
+
8
+ # Outbound address policy (#89): whether an address about to be dialed — a DNS answer, a redirect target, a literal origin — may be dialed at all, decided at the dial so that no parser routes around it.
9
+ # Trust comes from provenance, never from the address itself: a configured mirror may name any address but link-local (a private company mirror, a LAN instance to chain to), while a listed mirror and a client-named origin may name no internal address, so neither a listed host, nor its DNS, nor a client can make aptriever dial the operator's networks.
10
+ # The internal classes are listed explicitly — loopback, link-local, RFC 1918 private, shared address space, unique-local and the deprecated site-local, unspecified, multicast — and everything else is permitted, documentation, benchmarking and other reserved ranges included: the threat is reaching the operator's networks, and those ranges reach nobody.
11
+ # Not ipaddress.is_global or is_private: both classify the documentation ranges as non-global and private, which says more than the threat model does and would refuse the TEST-NET addresses the test suite's resolvers answer with.
12
+ # Link-local (169.254.0.0/16, fe80::/10) is refused whatever the provenance: the IPv4 cloud metadata address 169.254.169.254 lives there and no mirror does.
13
+ # A metadata endpoint in another class is covered by that class rather than by name — AWS's opt-in IPv6 endpoint fd00:ec2::254 is unique-local, Alibaba's 100.100.100.200 is shared address space — so both are refused for untrusted provenance and dialable through a configured mirror, which is the operator's own trust, and the policy stays a list of classes rather than of addresses.
14
+ # An IPv4-mapped address and one under a NAT64 prefix take the embedded IPv4's verdict: a translator delivers a wrapped 10.0.0.5 or 169.254.169.254 all the same, and ipaddress sees nothing link-local through a NAT64 prefix; the unwrap decodes the /96 layout only, so an address under the /48 prefix in one of RFC 6052's shorter layouts keeps its IPv4 above the low 32 bits, which read as unspecified (/48, /56) or as one stray byte of it (/64), and a network-specific prefix is not recognized at all.
15
+ # Other IPv4-in-IPv6 embeddings — 6to4 (2002::/16), Teredo (2001::/32), ISATAP, the deprecated IPv4-compatible and SIIT forms — were considered and left alone: a DNS answer cannot create the tunnel interface such an address would need.
16
+ # A string that does not parse as an address is refused: the policy vouches only for what it can classify.
17
+
18
+ NAT64_PREFIXES = (IPv6Network("64:ff9b::/96"), IPv6Network("64:ff9b:1::/48")) # RFC 6052's well-known prefix and RFC 8215's local-use prefix: under both the embedded IPv4 is the low 32 bits
19
+ INTERNAL_NETWORKS = ( # the internal classes ipaddress has no predicate of its own for, named as the log line reports them; loopback, link-local, unspecified and multicast come from its predicates
20
+ ("private", IPv4Network("10.0.0.0/8")),
21
+ ("private", IPv4Network("172.16.0.0/12")),
22
+ ("private", IPv4Network("192.168.0.0/16")),
23
+ ("shared", IPv4Network("100.64.0.0/10")),
24
+ ("unique-local", IPv6Network("fc00::/7")),
25
+ ("site-local", IPv6Network("fec0::/10")),
26
+ )
27
+
28
+
29
+ def _unwrapped(ip: str) -> IPv4Address | IPv6Address | None:
30
+ # The address the verdict is about — the embedded IPv4 of a mapped or NAT64 address — or None for a string that is not an address.
31
+ try:
32
+ address = ip_address(ip)
33
+ except ValueError:
34
+ return None
35
+ if isinstance(address, IPv6Address):
36
+ if address.ipv4_mapped is not None:
37
+ return address.ipv4_mapped
38
+ if any(address in prefix for prefix in NAT64_PREFIXES):
39
+ return IPv4Address(int(address) & 0xFFFFFFFF)
40
+ return address
41
+
42
+
43
+ def refusal(ip: str, trusted: bool) -> str | None:
44
+ # The class this address is refused under, None when it may be dialed. trusted: the provenance vouches for every internal class but link-local; otherwise for none of them.
45
+ address = _unwrapped(ip)
46
+ if address is None:
47
+ return "unparsable"
48
+ if address.is_link_local:
49
+ return "link-local"
50
+ if trusted:
51
+ return None
52
+ if address.is_loopback:
53
+ return "loopback"
54
+ if address.is_unspecified:
55
+ return "unspecified"
56
+ if address.is_multicast:
57
+ return "multicast"
58
+ return next((name for name, network in INTERNAL_NETWORKS if address in network), None)
59
+
60
+
61
+ def permitted(ip: str, trusted: bool) -> bool:
62
+ return refusal(ip, trusted) is None
63
+
64
+
65
+ IF_INET6 = Path("/proc/net/if_inet6") # every IPv6 address the kernel holds, one line each: address as 32 hex digits, interface index, prefix length, scope and flags as two hex digits each, interface name — the format ifconfig has read since Linux 2.2 (spec: egress address)
66
+ GLOBAL_UNICAST = IPv6Network("2000::/3") # RFC 4291's global unicast, which is what a provider delegates; ipaddress's is_global would also refuse the documentation prefix the tests use, and the scope byte alone admits unique-local addresses (spec: egress address)
67
+ # linux/if_addr.h; the low byte is all the proc file prints and all this needs: a temporary address rotates and a deprecated one is what a withdrawn or unrouted prefix leaves behind (RFC 4862 section 5.5.4; spec: egress address)
68
+ IFA_F_TEMPORARY, IFA_F_DADFAILED, IFA_F_DEPRECATED, IFA_F_TENTATIVE = 0x01, 0x08, 0x20, 0x40
69
+ ROUTE_PROBE = ("2606:4700:4700::1111", 9) # any global address does: a datagram connect() is a route lookup that sends nothing, and on a host with no IPv6 default it fails, which is the no-egress-address answer (spec: egress address)
70
+
71
+
72
+ def egress_addresses(table: str, default_source: str | None) -> list[str]:
73
+ # spec: egress address — the global, stable, preferred addresses on the interface the kernel's default source sits on, in the table's order; nothing without a default source, or with one the table does not list.
74
+ rows: list[tuple[str, int, str]] = []
75
+ for line in table.splitlines():
76
+ parts = line.split()
77
+ if len(parts) != 6:
78
+ continue # not a row of this file: skipped, never fatal, like every other system-provided text the daemon reads
79
+ try:
80
+ rows.append((str(IPv6Address(bytes.fromhex(parts[0]))), int(parts[4], 16), parts[5]))
81
+ except ValueError:
82
+ continue
83
+ interface = next((ifname for address, _, ifname in rows if address == default_source), None)
84
+ if interface is None:
85
+ return []
86
+ return [address for address, flags, ifname in rows if ifname == interface and IPv6Address(address) in GLOBAL_UNICAST and not flags & (IFA_F_TEMPORARY | IFA_F_DADFAILED | IFA_F_DEPRECATED | IFA_F_TENTATIVE)]
87
+
88
+
89
+ def default_source() -> str | None:
90
+ # The address the kernel would send from to a global destination, hence the interface egress addresses sit on; None when nothing routes there, or the host has no IPv6 at all (spec: egress address).
91
+ try:
92
+ with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as sock:
93
+ sock.connect(ROUTE_PROBE)
94
+ return sock.getsockname()[0]
95
+ except OSError:
96
+ return None
97
+
98
+
99
+ def system_egress_addresses() -> list[str]:
100
+ # The default `egress` seam of a MirrorSet: read fresh on every call, 27 µs measured, never cached, so a new prefix is dialed by the next seat and a withdrawn one skipped by the next pass (spec: egress address).
101
+ try:
102
+ return egress_addresses(IF_INET6.read_text(), default_source())
103
+ except OSError:
104
+ return [] # no such file: not Linux, or a sandbox — no egress address, every dial stays unbound as before
105
+
106
+
107
+ bound_egress: contextvars.ContextVar[str] = contextvars.ContextVar("bound_egress", default="") # the egress address the dials of the current task bind, set by open_upstream around its request loop; "" binds nothing (spec: dial)
108
+
109
+
110
+ def bound_socket(addr_info: tuple) -> socket.socket:
111
+ # aiohttp's socket_factory (the connector in server.py): every socket the connector dials with passes through here, so one session serves every egress address (spec: dial). A bind that fails — the address gone between seating and dialing — raises out of the factory, which aiohappyeyeballs turns into the connect failure open_upstream already classifies.
112
+ family, type_, proto, _, _ = addr_info
113
+ sock = socket.socket(family, type_, proto)
114
+ egress = bound_egress.get()
115
+ if egress and (family == socket.AF_INET6) == (":" in egress): # a redirect hop into the other family dials unbound: the family rule applied per hop, since an address of one family can never be bound to a socket of the other (spec: dial)
116
+ try:
117
+ sock.bind((egress, 0))
118
+ except OSError:
119
+ sock.close() # the factory owns the socket until it hands it over
120
+ raise
121
+ return sock
aptriever/aliases.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from .mirrors import MirrorSet
4
+
5
+
6
+ def resolve_pool_alias(mirror_sets: dict[str, MirrorSet], netloc: str, path: str) -> tuple[str, str] | None:
7
+ # Matches a request's host+path spelling against every configured archive's live pool (seed and geo arrivals alike) and returns (archive name, archive-relative remainder), or None (spec: docs/plans/2026-07-18-pool-mirror-aliases.md).
8
+ # A linear scan on purpose: it runs only for embedded requests and would-be-404s, and a live scan needs no invalidation wiring when the geo refresher appends mirrors.
9
+ want = netloc.lower()
10
+ best: tuple[str, str] | None = None
11
+ best_len = -1 # below zero so a bare-host mirror (prefix length 0) can still win the strict longest-match comparison
12
+ for archive, mirror_set in mirror_sets.items():
13
+ for mirror in mirror_set.mirrors:
14
+ # Scheme ignored: the client names a repository, and the pool fetches with its own configured schemes and redirect-upgrade memory. Netloc lowercased and port-inclusive: DNS names are case-insensitive, distinct ports are distinct servers.
15
+ mirror_netloc, prefix = mirror.alias_key
16
+ if mirror_netloc != want:
17
+ continue
18
+ if prefix:
19
+ # Segment-bounded prefix: the boundary slash is part of the match, so "ubuntu" never claims "ubuntu-ports/…"; a multi-segment mirror path consumes all its segments.
20
+ if not path.startswith(prefix + "/"):
21
+ continue
22
+ remainder = path[len(prefix) + 1 :]
23
+ else:
24
+ # A bare-host mirror serves the repository at its root, so every path under that host is archive-relative as-is.
25
+ remainder = path
26
+ if not remainder:
27
+ continue # the request names the repository root with nothing to fetch: no match
28
+ if len(prefix) > best_len:
29
+ # Strictly longer only: equal-length matches keep the first seen, so ties resolve in config order (dict iteration is [archives.*] order); equal-length prefixes of one path are the same string, so the tie can only be across archives.
30
+ best, best_len = (archive, remainder), len(prefix)
31
+ return best
aptriever/cache.py ADDED
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools
5
+ import logging
6
+ import os
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ log = logging.getLogger("aptriever")
13
+
14
+ PARTIAL_DIRNAME = "partial" # holds only in-flight fills; startup_cleanup and sweep both key off this exact name
15
+
16
+
17
+ def _urlsafe_key(archive: str, path: str) -> str:
18
+ # "/" is the only character archive/path legitimately contain that would otherwise create a nested directory under partial/; flattening it keeps every temp name one filesystem entry.
19
+ return f"{archive}/{path}".replace("/", "_")
20
+
21
+
22
+ class FillCancelled(Exception):
23
+ # A cancelled fill's followers were not themselves cancelled: handing them the CancelledError would falsely propagate cancellation into their own control flow, so it is converted to this ordinary exception before being stored as the shared error.
24
+ pass
25
+
26
+
27
+ def _reject_traversal(archive: str, path: str) -> None:
28
+ # Defense in depth: the server already collapses "." and rejects ".." before ever calling in, but this is the exact boundary that turns a client-controlled string into a filesystem path, so it re-checks rather than trusting the caller.
29
+ segments = archive.split("/") + path.split("/")
30
+ if ".." in segments or "." in segments: # "." too: pathlib drops it, so pool/./a.deb and pool/a.deb would be one entry under two request paths, and a request ending in "/." would take a directory's name (#90)
31
+ raise ValueError(f"path traversal rejected: archive={archive!r} path={path!r}")
32
+ if archive.startswith("/") or path.startswith("/"):
33
+ raise ValueError(f"absolute segment rejected: archive={archive!r} path={path!r}") # pathlib's / operator discards everything to its left when the right side is absolute, silently escaping root
34
+
35
+
36
+ class CacheFill:
37
+ def __init__(self, cache: Cache, archive: str, path: str, start_fill: Callable[[], Awaitable[Any]]) -> None:
38
+ self.start_fill = start_fill
39
+ self.final_path = cache.file_path(archive, path)
40
+ serial = next(cache._fill_serial)
41
+ cache.partial_dir.mkdir(parents=True, exist_ok=True) # here rather than in Cache.__init__: a fill is the only thing that needs the spool, so building a Cache to purge, clean or refuse one entry no longer creates a cache tree nothing has written to
42
+ # The urlsafe key alone is not enough: a failed fill and its retry share one key, so this cache-wide serial guarantees their temp names never collide even if the first fill's cleanup is still in flight.
43
+ self.temp_path = cache.partial_dir / f"{_urlsafe_key(archive, path)}.{serial}.tmp"
44
+ # Created now, synchronously, before any CacheReader can exist for this fill: a reader may start read() before the fill task first runs, and "not done implies the temp is openable" (CacheReader.read) needs the file to predate every reader; exist_ok=False turns a serial collision into a loud failure instead of silent data mixing.
45
+ self.temp_path.touch(exist_ok=False)
46
+ self.condition = asyncio.Condition()
47
+ self.bytes_written = 0
48
+ self.total: int | None = None
49
+ self.done = False
50
+ self.error: BaseException | None = None
51
+ self.task: asyncio.Task[None] | None = None # set by Cache.fill_or_join right after construction
52
+
53
+ async def run(self, cache: Cache, key: tuple[str, str]) -> None:
54
+ result: Any = None
55
+ error: BaseException | None = None
56
+ drained = False
57
+ try:
58
+ result = await self.start_fill()
59
+ length = result.headers.get("Content-Length")
60
+ async with self.condition:
61
+ self.total = int(length) if length is not None else None
62
+ self.condition.notify_all()
63
+ with open(self.temp_path, "wb") as f:
64
+ async for chunk in result.body:
65
+ f.write(chunk)
66
+ f.flush() # push past Python's own buffering into the OS now, not later: tail-followers read this file through their own fd and must see bytes as they land
67
+ async with self.condition:
68
+ self.bytes_written += len(chunk)
69
+ self.condition.notify_all()
70
+ drained = True # FetchResult.on_committed's contract, kept below: called once, if and only if the body's iteration ended without raising, and only after the terminal duties; set before the file closes so a failing close reaches it like a failing commit (#91)
71
+ # A missing Content-Length leaves total None and length verification impossible: aiohttp surfaces truncation of such transfers as body errors, and apt's hash check backstops the rest.
72
+ if self.total is not None and self.bytes_written != self.total:
73
+ raise ValueError(f"fill for {key!r} got {self.bytes_written} bytes, expected {self.total}") # never escapes run: recorded as self.error below for the readers to raise, like every other fill failure
74
+ except asyncio.CancelledError:
75
+ # Followers were not themselves cancelled: storing the CancelledError as the shared error would inject cancellation into their unrelated control flow, so convert it to an ordinary exception first.
76
+ error = FillCancelled(f"fill for {key!r} was cancelled")
77
+ raise # the finally below still runs its terminal duties, then cancellation keeps propagating so the task ends cancelled
78
+ except Exception as e:
79
+ error = e
80
+ finally:
81
+ async with self.condition:
82
+ try:
83
+ if error is None:
84
+ try:
85
+ self.final_path.parent.mkdir(parents=True, exist_ok=True)
86
+ except FileExistsError, NotADirectoryError:
87
+ # A regular file stands where this fill needs a directory: a directory listing cached under the directory's own name, which a mirror's redirect from …/l to …/l/ can still land there (#90). The fetch that just completed proves upstream has a directory at that path, so the stale file gives way and the mkdir is retried once; a second failure is the fill's failure like any other.
88
+ # Contained by construction: final_path is root/archive/path with traversal and absolute segments rejected, so this walk meets root before it can leave the cache, and the spool is a directory under a reserved archive name, never a regular file on a fill's path.
89
+ for ancestor in self.final_path.parents:
90
+ if ancestor == cache.root:
91
+ break
92
+ if ancestor.is_file():
93
+ ancestor.unlink(missing_ok=True) # missing_ok: a concurrent `aptriever cache clean` may beat this fill to the file
94
+ log.warning("fill for %r evicted %s: a file stood where the fill needs a directory, most likely a mirror's listing served under an object path (#90)", key, ancestor) # WARNING so the self-heal shows in journald: the file was a cache entry some request created
95
+ break
96
+ self.final_path.parent.mkdir(parents=True, exist_ok=True)
97
+ # Deliberately no fsync: this is a cache, not an ACID database — ext4's data=ordered mode flushes file data before a rename committing it is observable, and the rare crash-corrupted entry is caught by apt's own hash verification (heal by deleting).
98
+ # Inside the condition block on purpose: the rename and the done flag below flip atomically for condition-holding readers, so "not done" always implies "temp still openable".
99
+ os.replace(self.temp_path, self.final_path)
100
+ except Exception as e:
101
+ error = e # a failed commit (mkdir/rename, e.g. disk full) is the fill's failure: recorded for late readers, while readers that already drained every advertised byte still complete cleanly
102
+ log.warning("fill for %r could not be committed, the entry is not cached and every request re-fetches it: %s", key, e) # WARNING like the other cache-side faults: a daemon-level fault, not per-request narration (#91)
103
+ finally:
104
+ # Terminal duties, unskippable even when the commit above raised: without done+notify every waiting reader hangs forever, and without deregistration the dead fill stays joinable.
105
+ self.error = error
106
+ self.done = True
107
+ cache._forget_fill(key, self)
108
+ self.condition.notify_all()
109
+ if error is not None:
110
+ try:
111
+ self.temp_path.unlink(missing_ok=True)
112
+ except OSError as e:
113
+ log.warning("fill for %r could not remove its temporary file %s: %s", key, self.temp_path, e) # WARNING like the other cache-side faults, and swallowed: this runs in a task nothing awaits, where a raise reaches nobody but asyncio's never-retrieved report
114
+ finally:
115
+ if result is not None:
116
+ # Non-complete path only: a fully-drained body has already released everything through FetchResult's own bookkeeping, so closing again on success would be a no-op we don't need.
117
+ await result.close()
118
+ if drained and result.on_committed is not None:
119
+ # After the terminal block, so a written fill event implies done, deregistered, and either landed or failed for the reason it carries. Exactly once: the body wrapper writes the event itself when the iteration raised, and drained is True precisely when it did not (#91).
120
+ result.on_committed(error)
121
+ if error is None:
122
+ # Strictly after the terminal block above (done/notify/deregister already happened): eviction only ever runs when content was added, and a failed fill never grew the cache.
123
+ try:
124
+ # A directory walk per fill is fine at these request rates. TODO: revisit with an index when the LAN profile shows it in profiles.
125
+ cache.sweep()
126
+ except Exception as e:
127
+ log.warning("sweep after fill for %r failed: %s", key, e)
128
+
129
+
130
+ class CacheReader:
131
+ # Independent per caller: each fill_or_join call gets its own CacheReader with its own file position, so one slow client can never block another from draining the fill.
132
+ def __init__(self, fill: CacheFill, cache_kind: str) -> None:
133
+ self._fill = fill
134
+ self.cache_kind = cache_kind
135
+
136
+ @property
137
+ def total(self) -> int | None:
138
+ return self._fill.total
139
+
140
+ async def read(self) -> AsyncIterator[bytes]:
141
+ fill = self._fill
142
+ # Opened lazily on first iteration, not in __init__: a reader that is constructed but never iterated must not hold (and eventually leak) an open file.
143
+ async with fill.condition:
144
+ if fill.done:
145
+ if fill.error is not None:
146
+ raise fill.error # the fill ended before this reader ever started and its temp is already unlinked: the recorded error is all it can serve
147
+ f = open(fill.final_path, "rb") # the fill completed before this reader started: the temp was renamed into place, so the final file carries the identical content
148
+ else:
149
+ # Not done, observed under the condition, guarantees the temp still exists: rename and unlink both happen strictly on the done side of the terminal transition in CacheFill.run.
150
+ # This one fd is kept across the fill's later os.replace or unlink: POSIX rename and unlink only touch directory entries, an open fd keeps addressing the inode.
151
+ f = open(fill.temp_path, "rb")
152
+ try:
153
+ position = 0
154
+ while True:
155
+ async with fill.condition:
156
+ while fill.bytes_written <= position and not fill.done:
157
+ await fill.condition.wait()
158
+ available = fill.bytes_written
159
+ is_done = fill.done
160
+ error = fill.error
161
+ total = fill.total
162
+ if available > position:
163
+ f.seek(position)
164
+ chunk = f.read(available - position)
165
+ position += len(chunk)
166
+ yield chunk
167
+ if is_done and position >= available:
168
+ # A reader that delivered exactly the advertised byte count completes cleanly even if the fill failed afterwards (e.g. at the rename): its own stream was byte-perfect, only the caching failed.
169
+ if error is not None and not (total is not None and position == total):
170
+ raise error
171
+ return
172
+ finally:
173
+ f.close()
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class CachePolicy:
178
+ # All-None enforces nothing: sweep applies whichever limits are set. Deployed processes always carry at least one — config.resolve_cache_policy fills a mode default when the operator sets none.
179
+ cache_max_bytes: int | None = None # absolute footprint cap; wins over cache_max_percent when both are set
180
+ cache_max_percent: float | None = None # footprint cap as a percent of the filesystem's live total size
181
+ disk_high_water_percent: float | None = None # evict while the whole filesystem (any cause, not just the cache) is fuller than this percent
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class CacheUsage:
186
+ fs_total: int
187
+ fs_used: int
188
+ fs_avail: int
189
+ cache_bytes: int
190
+ cache_cap: int | None
191
+ disk_high_water: int | None
192
+
193
+ @property
194
+ def headroom(self) -> int | None:
195
+ # Bytes the cache may still grow before some limit bites; None when no limit is in force.
196
+ margins = [m for m in (self.cache_cap - self.cache_bytes if self.cache_cap is not None else None, self.disk_high_water - self.fs_used if self.disk_high_water is not None else None) if m is not None]
197
+ return max(0, min(margins)) if margins else None
198
+
199
+
200
+ class Cache:
201
+ def __init__(self, root: Path, policy: CachePolicy) -> None:
202
+ self.root = Path(root)
203
+ self.policy = policy
204
+ self.partial_dir = self.root / PARTIAL_DIRNAME
205
+ self._fills: dict[tuple[str, str], CacheFill] = {}
206
+ self._fill_serial = itertools.count() # cache-wide, not per-key: only uniqueness matters, and one shared counter is simpler than tracking per-key state
207
+
208
+ def file_path(self, archive: str, path: str) -> Path:
209
+ _reject_traversal(archive, path)
210
+ return self.root / archive / path
211
+
212
+ def lookup(self, archive: str, path: str) -> Path | None:
213
+ p = self.file_path(archive, path)
214
+ if not p.is_file():
215
+ return None
216
+ # Hits bump mtime as the LRU signal: atime is unreliable under relatime (only advances if the old atime predated today or predated mtime), so mtime is repurposed here, at the cost that it no longer means "when this was downloaded."
217
+ os.utime(p, None)
218
+ return p
219
+
220
+ async def fill_or_join(self, archive: str, path: str, start_fill: Callable[[], Awaitable[Any]]) -> CacheReader:
221
+ key = (archive, path)
222
+ fill = self._fills.get(key)
223
+ if fill is not None:
224
+ return CacheReader(fill, "tail")
225
+ # No await above this line: two concurrent callers for the same key can never both see a miss, because asyncio only switches coroutines at an await point and this whole registration is one uninterrupted step.
226
+ fill = CacheFill(self, archive, path, start_fill)
227
+ self._fills[key] = fill
228
+ fill.task = asyncio.create_task(fill.run(self, key))
229
+ return CacheReader(fill, "miss")
230
+
231
+ def _forget_fill(self, key: tuple[str, str], fill: CacheFill) -> None:
232
+ if self._fills.get(key) is fill:
233
+ del self._fills[key]
234
+
235
+ def startup_cleanup(self) -> int:
236
+ if not self.partial_dir.is_dir():
237
+ return 0 # nothing has ever filled through this cache, so there is no spool and nothing stale in it
238
+ count = 0
239
+ for entry in self.partial_dir.iterdir():
240
+ if entry.is_file():
241
+ entry.unlink()
242
+ count += 1
243
+ return count
244
+
245
+ def clean(self) -> tuple[int, int, list[OSError]]:
246
+ # Wipes every completed entry (all re-fetchable); in-flight fills under partial/ stay untouched. Safe against a running server: POSIX keeps an unlinked-but-open file readable, and the next sweep re-derives sizes from the tree.
247
+ files = 0
248
+ freed = 0
249
+ skipped: list[OSError] = [] # every directory the walk could not read, an absent root among them, and every entry that would not unlink: without them a count of zero cannot be told from an unreadable tree (#109)
250
+ for dirpath, dirnames, filenames in os.walk(self.root, onerror=skipped.append):
251
+ if Path(dirpath) == self.root:
252
+ dirnames[:] = [d for d in dirnames if d != PARTIAL_DIRNAME] # never touch in-flight fills
253
+ for name in filenames:
254
+ fp = Path(dirpath) / name
255
+ try:
256
+ size = fp.stat().st_size
257
+ fp.unlink()
258
+ except FileNotFoundError:
259
+ continue # vanished mid-walk (a concurrent sweep): already gone is the goal state
260
+ except OSError as e:
261
+ skipped.append(e) # a readable but unwritable directory: the rest of the tree still goes, and the caller reports this one
262
+ continue
263
+ files += 1
264
+ freed += size
265
+ return files, freed, skipped
266
+
267
+ def _cache_cap(self, fs_total: int) -> int | None:
268
+ if self.policy.cache_max_bytes is not None:
269
+ return self.policy.cache_max_bytes
270
+ if self.policy.cache_max_percent is not None:
271
+ return int(self.policy.cache_max_percent / 100 * fs_total) # the configured percent as the bytes every comparison here is in; truncated, so the cap never rounds past the share the operator named
272
+ return None
273
+
274
+ def _disk_high_water(self, fs_total: int) -> int | None:
275
+ if self.policy.disk_high_water_percent is not None:
276
+ return int(self.policy.disk_high_water_percent / 100 * fs_total) # the same percent-to-bytes conversion against the same live total: sweep() holds fs_used to this mark and cache_bytes to the cap above, all four in bytes
277
+ return None
278
+
279
+ def _survey(self) -> tuple[CacheUsage, list[tuple[float, Path, int]]]:
280
+ # statvfs is re-read on every survey, never cached: disks resize (LXD volumes especially), so percent limits must resolve against the live total.
281
+ st = os.statvfs(self.root)
282
+ fs_total = st.f_frsize * st.f_blocks
283
+ fs_avail = st.f_frsize * st.f_bavail # f_bavail, not f_bfree: the daemon runs unprivileged, so root's reserved blocks are not usable headroom
284
+ fs_used = fs_total - fs_avail
285
+ entries: list[tuple[float, Path, int]] = []
286
+ cache_bytes = 0
287
+ # One WARNING per unreadable directory per survey: only an operator can produce one under the daemon's own user, and the sweep under-counts cache_bytes and evicts nothing from that subtree until it is fixed, so repeating the line while it persists is the point (#109).
288
+ for dirpath, dirnames, filenames in os.walk(self.root, onerror=lambda e: log.warning("cache: survey cannot read %s: %s", e.filename, e.strerror)):
289
+ if Path(dirpath) == self.root:
290
+ dirnames[:] = [d for d in dirnames if d != PARTIAL_DIRNAME] # never touch in-flight fills
291
+ for name in filenames:
292
+ fp = Path(dirpath) / name
293
+ try:
294
+ stat = fp.stat()
295
+ except FileNotFoundError:
296
+ continue # vanished mid-walk (a concurrent `aptriever clean`): simply not part of this survey
297
+ cache_bytes += stat.st_size
298
+ entries.append((stat.st_mtime, fp, stat.st_size))
299
+ entries.sort(key=lambda e: e[0]) # oldest mtime first
300
+ return CacheUsage(fs_total=fs_total, fs_used=fs_used, fs_avail=fs_avail, cache_bytes=cache_bytes, cache_cap=self._cache_cap(fs_total), disk_high_water=self._disk_high_water(fs_total)), entries
301
+
302
+ def usage(self) -> CacheUsage:
303
+ usage, _ = self._survey()
304
+ return usage
305
+
306
+ def oversized(self, total_bytes: int) -> bool:
307
+ # An entry bigger than a cap in force could only land by evicting everything else and would still be over (issue #31's pathology): the server skips caching it and streams it straight to clients instead.
308
+ st = os.statvfs(self.root)
309
+ fs_total = st.f_frsize * st.f_blocks
310
+ caps = [c for c in (self._cache_cap(fs_total), self._disk_high_water(fs_total)) if c is not None]
311
+ return bool(caps) and total_bytes > min(caps)
312
+
313
+ def sweep(self) -> int:
314
+ usage, entries = self._survey()
315
+ cache_bytes, disk_used = usage.cache_bytes, usage.fs_used
316
+ log.debug("sweep: fs total=%d used=%d avail=%d cache=%d cap=%s high_water=%s", usage.fs_total, disk_used, usage.fs_avail, cache_bytes, usage.cache_cap, usage.disk_high_water)
317
+ freed = 0
318
+ for _, fp, size in entries:
319
+ if (usage.cache_cap is None or cache_bytes <= usage.cache_cap) and (usage.disk_high_water is None or disk_used <= usage.disk_high_water):
320
+ break
321
+ fp.unlink(missing_ok=True) # missing_ok: a concurrent clean may beat this sweep to the file; the pass's accounting stays approximate either way
322
+ cache_bytes -= size
323
+ disk_used -= size
324
+ freed += size
325
+ else:
326
+ if usage.disk_high_water is not None and disk_used > usage.disk_high_water:
327
+ # Best-effort boundary: everything evictable is gone and the filesystem is still over the high-water — non-cache data holds the space, which only the operator can reclaim.
328
+ log.warning("disk use %d still over the %d high-water after evicting the entire cache", disk_used, usage.disk_high_water)
329
+ return freed