linkward 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.
linkward/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Read local files without following symlinks in any path component.
2
+
3
+ ``safe_open`` returns bytes after checking policy on the opened descriptor.
4
+ ``open_nofollow`` returns a caller-owned descriptor with only the path guarantee.
5
+ Metadata checks provide best-effort change detection, not a content snapshot or
6
+ proof of authenticity. Linux and macOS; standard library only.
7
+ """
8
+
9
+ from .core import (
10
+ FileChangedDuringRead,
11
+ FilePolicyError,
12
+ SafeOpenError,
13
+ SymlinkRejected,
14
+ open_nofollow,
15
+ safe_open,
16
+ )
17
+
18
+ __all__ = [
19
+ "FileChangedDuringRead",
20
+ "FilePolicyError",
21
+ "SafeOpenError",
22
+ "SymlinkRejected",
23
+ "open_nofollow",
24
+ "safe_open",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
linkward/core.py ADDED
@@ -0,0 +1,353 @@
1
+ """Symlink-rejecting reads with policy checks on the opened file descriptor.
2
+
3
+ Directory descriptors anchor each step of path traversal. ``O_NOFOLLOW`` rejects
4
+ links rather than checking a path and later trusting it to stay unchanged.
5
+ Metadata comparisons detect some concurrent writes; they do not provide a snapshot.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import errno
11
+ import os
12
+ import stat as stat_module
13
+ from typing import NoReturn
14
+
15
+ __all__ = [
16
+ "FileChangedDuringRead",
17
+ "FilePolicyError",
18
+ "SafeOpenError",
19
+ "SymlinkRejected",
20
+ "open_nofollow",
21
+ "safe_open",
22
+ ]
23
+
24
+
25
+ class SafeOpenError(Exception):
26
+ """Base class for every linkward failure."""
27
+
28
+
29
+ class SymlinkRejected(SafeOpenError):
30
+ """A symlink was encountered in a path component, or the target itself is one."""
31
+
32
+
33
+ class FileChangedDuringRead(SafeOpenError):
34
+ """The observed metadata or read length indicates a concurrent change."""
35
+
36
+
37
+ class FilePolicyError(SafeOpenError):
38
+ """The opened file did not satisfy a required mode / owner / size / type policy."""
39
+
40
+
41
+ # O_NOFOLLOW: if the final component is a symlink, fail instead of following it.
42
+ # O_DIRECTORY: assert the component we just opened is a directory (used while descending).
43
+ # O_CLOEXEC: never leak these fds across an exec.
44
+ # O_NONBLOCK avoids a FIFO open waiting for a writer. It is not an I/O timeout:
45
+ # regular files and some devices/filesystems can still block.
46
+ _REQUIRED_FLAGS = ("O_NOFOLLOW", "O_DIRECTORY", "O_CLOEXEC", "O_NONBLOCK")
47
+
48
+ # Read in bounded chunks so a hostile/huge file can't force an unbounded allocation.
49
+ _CHUNK = 65536
50
+
51
+
52
+ def _require_absolute(path: str | os.PathLike[str]) -> tuple[str, list[str]]:
53
+ """Convert a PathLike once, then validate its text without resolving links."""
54
+ try:
55
+ path = os.fspath(path)
56
+ except TypeError as exc:
57
+ raise SafeOpenError(
58
+ "path must be a string or a PathLike returning a string"
59
+ ) from exc
60
+ if not isinstance(path, str) or not path or "\x00" in path:
61
+ raise SafeOpenError("path must be non-empty text without NUL characters")
62
+ try:
63
+ os.fsencode(path)
64
+ except UnicodeEncodeError as exc:
65
+ raise SafeOpenError("path cannot be encoded for this filesystem") from exc
66
+ if not path.startswith("/"):
67
+ raise SafeOpenError("path must be absolute (start with '/')")
68
+ parts = [component for component in path.split("/") if component]
69
+ if any(component in (".", "..") for component in parts):
70
+ raise SafeOpenError("path must not contain '.' or '..' components")
71
+ if not parts:
72
+ raise SafeOpenError("path must reference a file, not the filesystem root")
73
+ if path.endswith("/"):
74
+ raise SafeOpenError("path must not end with '/'")
75
+ return path, parts
76
+
77
+
78
+ def open_nofollow(path: str | os.PathLike[str]) -> int:
79
+ """Open ``path`` for reading with no symlink permitted in any component.
80
+
81
+ Returns a non-inheritable, non-blocking descriptor. The caller owns it and
82
+ must ``os.close`` it. No file type, owner, mode, size, or content checks are
83
+ performed. During a path race, a rejected link may be reported as the broader
84
+ :class:`SafeOpenError` if its type can no longer be determined.
85
+
86
+ This is the low-level primitive; most callers want :func:`safe_open`.
87
+ """
88
+ path, parts = _require_absolute(path)
89
+ return _open_components(path, parts)
90
+
91
+
92
+ def _open_components(path: str, parts: list[str]) -> int:
93
+ if os.name != "posix" or any(not hasattr(os, flag) for flag in _REQUIRED_FLAGS):
94
+ raise SafeOpenError(
95
+ "this platform lacks the required POSIX no-follow open flags"
96
+ )
97
+ dir_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC
98
+ leaf_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK
99
+ *dirs, leaf = parts
100
+
101
+ try:
102
+ parent_fd = os.open("/", dir_flags)
103
+ except OSError as exc:
104
+ raise SafeOpenError(f"cannot open filesystem root for {path!r}: {exc}") from exc
105
+ leaf_fd = None
106
+ try:
107
+ for component in dirs:
108
+ try:
109
+ next_fd = os.open(component, dir_flags, dir_fd=parent_fd)
110
+ except OSError as exc:
111
+ _reclassify_component(exc, component, parent_fd, path, is_leaf=False)
112
+ else:
113
+ previous_fd = parent_fd
114
+ parent_fd = next_fd
115
+ _close_fd(previous_fd)
116
+
117
+ try:
118
+ leaf_fd = os.open(leaf, leaf_flags, dir_fd=parent_fd)
119
+ except OSError as exc:
120
+ _reclassify_component(exc, leaf, parent_fd, path, is_leaf=True)
121
+ except NotImplementedError as exc:
122
+ raise SafeOpenError(
123
+ "this platform lacks descriptor-relative open support"
124
+ ) from exc
125
+ finally:
126
+ try:
127
+ _close_fd(parent_fd)
128
+ except BaseException:
129
+ # Do not lose an opened leaf if closing its parent prevents return.
130
+ if leaf_fd is not None:
131
+ _close_fd(leaf_fd)
132
+ raise
133
+ return leaf_fd
134
+
135
+
136
+ def _close_fd(fd: int) -> None:
137
+ try:
138
+ os.close(fd)
139
+ except OSError as exc:
140
+ # Never retry close: the OS may already have released/reused this number.
141
+ raise SafeOpenError(f"cannot close file descriptor {fd}: {exc}") from exc
142
+
143
+
144
+ def safe_open(
145
+ path: str | os.PathLike[str],
146
+ *,
147
+ max_bytes: int = 1024 * 1024,
148
+ require_mode: int | None = None,
149
+ require_owner: int | None = None,
150
+ require_regular_file: bool = True,
151
+ require_single_hardlink: bool = False,
152
+ ) -> bytes:
153
+ """Read bytes without following links, checking policy on the opened file.
154
+
155
+ Two ``fstat`` observations check device/inode, size, mtime, and ctime, and the
156
+ read length must agree with the initial size. Matching metadata does not prove
157
+ unchanged contents: concurrent writes (including mmap writes) can escape these
158
+ checks. This is not an atomic snapshot or a directory confinement primitive.
159
+
160
+ Parameters
161
+ ----------
162
+ path:
163
+ Absolute text path or PathLike[str]. No ``.``/``..`` components, trailing
164
+ slash, or symlinks. The text returned by ``os.fspath`` is validated.
165
+ max_bytes:
166
+ Non-negative integer limit on returned bytes. Reads at most one extra
167
+ byte to detect growth. Buffer assembly can use more than this much memory.
168
+ require_mode:
169
+ If set, the file's permission bits (``st_mode & 0o777``) must equal this,
170
+ e.g. ``0o600`` for a credentials file. Otherwise :class:`FilePolicyError`.
171
+ require_owner:
172
+ If set, the file's ``st_uid`` must equal this (typically ``os.geteuid()``).
173
+ require_regular_file:
174
+ If True (default), reject anything that is not a regular file
175
+ (fifos, devices, directories, sockets).
176
+ require_single_hardlink:
177
+ If True, require ``st_nlink == 1`` at the initial policy check. This is
178
+ not proof that no other process can write the file.
179
+
180
+ Returns
181
+ -------
182
+ bytes
183
+ The file's contents.
184
+ """
185
+ _validate_policy(
186
+ max_bytes,
187
+ require_mode,
188
+ require_owner,
189
+ require_regular_file,
190
+ require_single_hardlink,
191
+ )
192
+ path, parts = _require_absolute(path)
193
+
194
+ fd = _open_components(path, parts)
195
+ try:
196
+ before = os.fstat(fd)
197
+
198
+ _enforce_policy(
199
+ before,
200
+ path=path,
201
+ max_bytes=max_bytes,
202
+ require_mode=require_mode,
203
+ require_owner=require_owner,
204
+ require_regular_file=require_regular_file,
205
+ require_single_hardlink=require_single_hardlink,
206
+ )
207
+
208
+ data = _read_all(fd, max_bytes=max_bytes, path=path)
209
+
210
+ after = os.fstat(fd)
211
+ _assert_unchanged(before, after, path=path, read_len=len(data))
212
+ return data
213
+ except OSError as exc:
214
+ raise SafeOpenError(f"cannot read or inspect {path!r}: {exc}") from exc
215
+ finally:
216
+ _close_fd(fd)
217
+
218
+
219
+ def _validate_policy(
220
+ max_bytes: int,
221
+ require_mode: int | None,
222
+ require_owner: int | None,
223
+ require_regular_file: bool,
224
+ require_single_hardlink: bool,
225
+ ) -> None:
226
+ for name, value in (
227
+ ("max_bytes", max_bytes),
228
+ ("require_mode", require_mode),
229
+ ("require_owner", require_owner),
230
+ ):
231
+ if value is None and name != "max_bytes":
232
+ continue
233
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
234
+ raise SafeOpenError(f"{name} must be a non-negative integer")
235
+ if require_mode is not None and require_mode > 0o777:
236
+ raise SafeOpenError("require_mode must be between 0 and 0o777")
237
+ for name, value in (
238
+ ("require_regular_file", require_regular_file),
239
+ ("require_single_hardlink", require_single_hardlink),
240
+ ):
241
+ if not isinstance(value, bool):
242
+ raise SafeOpenError(f"{name} must be a bool")
243
+
244
+
245
+ def _enforce_policy(
246
+ st: os.stat_result,
247
+ *,
248
+ path: str,
249
+ max_bytes: int,
250
+ require_mode: int | None,
251
+ require_owner: int | None,
252
+ require_regular_file: bool,
253
+ require_single_hardlink: bool,
254
+ ) -> None:
255
+ if require_regular_file and not stat_module.S_ISREG(st.st_mode):
256
+ raise FilePolicyError(f"{path!r} is not a regular file")
257
+ if require_mode is not None and (st.st_mode & 0o777) != require_mode:
258
+ raise FilePolicyError(
259
+ f"{path!r} has mode {st.st_mode & 0o777:#o}, required {require_mode:#o}"
260
+ )
261
+ if require_owner is not None and st.st_uid != require_owner:
262
+ raise FilePolicyError(
263
+ f"{path!r} is owned by uid {st.st_uid}, required uid {require_owner}"
264
+ )
265
+ if require_single_hardlink and st.st_nlink != 1:
266
+ raise FilePolicyError(
267
+ f"{path!r} has {st.st_nlink} hardlinks, required exactly 1"
268
+ )
269
+ if st.st_size > max_bytes:
270
+ raise FilePolicyError(
271
+ f"{path!r} is {st.st_size} bytes, exceeds max_bytes={max_bytes}"
272
+ )
273
+
274
+
275
+ def _read_all(fd: int, *, max_bytes: int, path: str) -> bytes:
276
+ """Read up to max_bytes+1 so we can detect a file that grew past the limit."""
277
+ chunks: list[bytes] = []
278
+ total = 0
279
+ while total <= max_bytes:
280
+ chunk = os.read(fd, min(_CHUNK, max_bytes - total + 1))
281
+ if not chunk:
282
+ break
283
+ chunks.append(chunk)
284
+ total += len(chunk)
285
+ if total > max_bytes:
286
+ raise FilePolicyError(
287
+ f"{path!r} grew past max_bytes={max_bytes} during read"
288
+ )
289
+ return b"".join(chunks)
290
+
291
+
292
+ def _assert_unchanged(
293
+ before: os.stat_result,
294
+ after: os.stat_result,
295
+ *,
296
+ path: str,
297
+ read_len: int,
298
+ ) -> None:
299
+ """Reject if the fd's underlying file changed identity, size, or timestamps.
300
+
301
+ This compares observations, not path names or content versions. Once opened,
302
+ a descriptor stays attached to its inode even if the path is replaced.
303
+ """
304
+ if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
305
+ raise FileChangedDuringRead(f"{path!r} changed identity (inode) during read")
306
+ if before.st_size != after.st_size:
307
+ raise FileChangedDuringRead(
308
+ f"{path!r} changed size during read ({before.st_size} -> {after.st_size})"
309
+ )
310
+ if (
311
+ before.st_mtime_ns != after.st_mtime_ns
312
+ or before.st_ctime_ns != after.st_ctime_ns
313
+ ):
314
+ raise FileChangedDuringRead(f"{path!r} was modified during read")
315
+ if read_len != before.st_size:
316
+ raise FileChangedDuringRead(
317
+ f"{path!r}: read {read_len} bytes but stat reported {before.st_size}"
318
+ )
319
+
320
+
321
+ def _reclassify_component(
322
+ exc: OSError,
323
+ component: str,
324
+ parent_fd: int,
325
+ path: str,
326
+ *,
327
+ is_leaf: bool,
328
+ ) -> NoReturn:
329
+ """Translate a failed no-follow open into the right exception.
330
+
331
+ ELOOP identifies a rejected link directly. With O_DIRECTORY some systems use
332
+ ENOTDIR instead; lstat is a best-effort diagnostic for those cases. The entry
333
+ can change between open and lstat, affecting only the exception subtype. No
334
+ failed open is retried, so classification cannot cause a link to be followed.
335
+ """
336
+ where = "target file" if is_leaf else "path component"
337
+ if exc.errno == errno.ELOOP:
338
+ raise SymlinkRejected(
339
+ f"{where} {component!r} in {path!r} is a symlink"
340
+ ) from exc
341
+ try:
342
+ st = os.lstat(component, dir_fd=parent_fd)
343
+ is_symlink = stat_module.S_ISLNK(st.st_mode)
344
+ except (OSError, NotImplementedError):
345
+ is_symlink = False
346
+
347
+ if is_symlink:
348
+ raise SymlinkRejected(
349
+ f"{where} {component!r} in {path!r} is a symlink"
350
+ ) from exc
351
+ raise SafeOpenError(
352
+ f"cannot open {where} {component!r} in {path!r}: {exc}"
353
+ ) from exc
linkward/py.typed ADDED
File without changes
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.3
2
+ Name: linkward
3
+ Version: 0.1.0
4
+ Summary: Read local files without following symlinks, with owner, mode, and size checks.
5
+ Project-URL: Homepage, https://github.com/alohaaaaaaaaaaaaaa/safeopen
6
+ Project-URL: Issues, https://github.com/alohaaaaaaaaaaaaaa/safeopen/issues
7
+ Project-URL: Source, https://github.com/alohaaaaaaaaaaaaaa/safeopen
8
+ Author: Elton Pang
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Elton Pang
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ Keywords: file,o_nofollow,race-condition,security,symlink,toctou
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: MacOS :: MacOS X
35
+ Classifier: Operating System :: POSIX :: Linux
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3 :: Only
38
+ Classifier: Programming Language :: Python :: 3.9
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Programming Language :: Python :: 3.14
44
+ Classifier: Topic :: Security
45
+ Classifier: Topic :: System :: Filesystems
46
+ Classifier: Typing :: Typed
47
+ Requires-Python: >=3.9
48
+ Provides-Extra: test
49
+ Requires-Dist: pytest>=7; extra == 'test'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # linkward
53
+
54
+ **Read a local file while rejecting symbolic links in every path component, with optional owner, permission, and size checks.**
55
+
56
+ Pure Python, zero runtime dependencies. Linux and macOS, Python 3.9+.
57
+
58
+ Use it when a Python service or CLI reads a credential or configuration file and
59
+ must reject symlink redirection. `linkward` opens each directory relative to the
60
+ previous directory's descriptor with `O_NOFOLLOW`, then opens and reads the final
61
+ file through the same descriptor used for policy checks.
62
+
63
+ | Read method | Rejects a symlink at the final file | Rejects symlinks in parent directories |
64
+ | --- | --- | --- |
65
+ | `open(path, "rb")` | No | No |
66
+ | `os.open(path, os.O_RDONLY \| os.O_NOFOLLOW)` | Yes | No |
67
+ | `safe_open(path)` | Yes | Yes |
68
+
69
+ This prevents symlink traversal during this open operation. It does **not** make
70
+ an attacker-writable regular file trustworthy or provide a snapshot of its contents.
71
+
72
+ ## Install
73
+
74
+ This project has not been published to PyPI. Install from the repository:
75
+
76
+ ```bash
77
+ python -m pip install 'git+https://github.com/alohaaaaaaaaaaaaaa/linkward.git'
78
+ ```
79
+
80
+ For a reproducible deployment, pin a reviewed commit after the `.git` URL with
81
+ `@<commit>`.
82
+
83
+ ## Read a credential
84
+
85
+ ```python
86
+ import os
87
+ from pathlib import Path
88
+
89
+ from linkward import SafeOpenError, safe_open
90
+
91
+ # A configured absolute path with no symlinks in any component.
92
+ credential_path = Path("/srv/myapp/token")
93
+
94
+ try:
95
+ token = safe_open(
96
+ credential_path,
97
+ require_owner=os.geteuid(),
98
+ require_mode=0o600,
99
+ max_bytes=64 * 1024,
100
+ )
101
+ except SafeOpenError:
102
+ # Abort startup rather than using a credential that failed these checks.
103
+ raise
104
+ ```
105
+
106
+ Owner and permission checks are **off by default**; enable them for your trust
107
+ policy. The example requires the effective user's ownership and exactly `0600`
108
+ ordinary permission bits. A matching owner and mode do not establish content
109
+ authenticity, and ACLs or other writers may still permit changes.
110
+
111
+ Paths must be absolute `str` or `os.PathLike[str]` values. Relative paths, `.` or
112
+ `..` components, NUL characters, the filesystem root, and trailing slashes are
113
+ rejected. Validation applies to the text returned by `os.fspath()`; a `Path`
114
+ object may already have removed `.` components or trailing slashes. Bytes paths
115
+ are unsupported.
116
+
117
+ Parent directories are opened read-only, so the caller needs directory read and
118
+ search permissions as well as permission to read the file.
119
+
120
+ On macOS, `/tmp`, `/var`, and `/etc` commonly contain or are symlinks. Configure a
121
+ trusted canonical path such as `/private/etc/myapp/token` if appropriate for your
122
+ system. Do not call `resolve()` or `realpath()` on an untrusted path just to make it
123
+ pass: those operations follow links, and a later open can encounter a changed path.
124
+
125
+ ## Run the attack demonstration
126
+
127
+ After installation, from a repository checkout:
128
+
129
+ ```bash
130
+ python examples/symlink_attack.py
131
+ ```
132
+
133
+ The demonstration compares ordinary `open()`, final-component-only `O_NOFOLLOW`,
134
+ and `safe_open()`. It also performs a controlled swap between a separate path
135
+ check and open. The timing is deliberate: this illustrates the failure mode and
136
+ the protection, rather than measuring a probabilistic race's success rate.
137
+
138
+ ## API
139
+
140
+ ```python
141
+ safe_open(
142
+ path,
143
+ *,
144
+ max_bytes=1024 * 1024,
145
+ require_mode=None,
146
+ require_owner=None,
147
+ require_regular_file=True,
148
+ require_single_hardlink=False,
149
+ ) -> bytes
150
+
151
+ open_nofollow(path) -> int
152
+ ```
153
+
154
+ | `safe_open` option | Behavior |
155
+ | --- | --- |
156
+ | `max_bytes` | Nonnegative integer; reject an oversized file. Returned bytes never exceed this limit. |
157
+ | `require_mode` | Optional integer from `0` to `0o777`; require an exact match with `st_mode & 0o777`. Special mode bits and ACLs are not checked by this option. |
158
+ | `require_owner` | Optional nonnegative user ID to match against `st_uid`, commonly `os.geteuid()`. |
159
+ | `require_regular_file` | Default `True`; reject directories, FIFOs, devices, sockets, and other nonregular files. |
160
+ | `require_single_hardlink` | Default `False`; if enabled, require `st_nlink == 1` at the initial inspection. This is a link-count check, not a guarantee of exclusive access. |
161
+
162
+ Integer options reject booleans; the two boolean options require actual `bool`
163
+ values. Policy checks use the initial descriptor metadata, followed by the
164
+ before/after change check. Setting `require_regular_file=False` does not turn this
165
+ into a general stream reader: size and read-length consistency checks still apply,
166
+ so pseudo-files that report a size of zero can be rejected despite having content.
167
+
168
+ Size detection may read one byte beyond `max_bytes` before rejecting a growing
169
+ file. Chunk storage and combining the result also require memory beyond the
170
+ returned bytes; `max_bytes` is not a strict process-memory limit.
171
+
172
+ `open_nofollow()` only provides the path traversal guarantee. It does not enforce
173
+ file type, owner, permissions, size, link count, or content stability. Its descriptor
174
+ is opened read-only and nonblocking and is not inherited across `exec`; the caller
175
+ must close it:
176
+
177
+ ```python
178
+ import os
179
+ from linkward import open_nofollow
180
+
181
+ fd = open_nofollow("/srv/myapp/token")
182
+ try:
183
+ metadata = os.fstat(fd)
184
+ # Apply your own policy and read from this descriptor.
185
+ finally:
186
+ os.close(fd)
187
+ ```
188
+
189
+ All library failures derive from `SafeOpenError`:
190
+
191
+ | Exception | Meaning |
192
+ | --- | --- |
193
+ | `SymlinkRejected` | A symbolic link was identified in a rejected path component. |
194
+ | `FilePolicyError` | The opened file did not meet the requested type, owner, mode, size, or link-count policy. |
195
+ | `FileChangedDuringRead` | Descriptor metadata or the amount read indicated a change during the read. |
196
+ | `SafeOpenError` | Invalid input, unsupported platform, or another filesystem failure. |
197
+
198
+ Filesystem `OSError`s are wrapped with the original exception in `__cause__` for
199
+ diagnostics. A concurrent path change can affect the exception classification;
200
+ the operation still rejects a component it cannot open safely.
201
+
202
+ ## Security boundaries
203
+
204
+ - **Concurrent content writes:** the before/after `fstat` comparison is a
205
+ best-effort change detector. Writes through `mmap`, filesystem timestamp
206
+ behavior, or other concurrent writes can leave indistinguishable metadata.
207
+ A successful read can contain a mixture of bytes from different moments.
208
+ There is no snapshot or authenticity guarantee. Use an appropriate publication
209
+ or integrity scheme when those guarantees are required.
210
+ - **Renames and directory replacement:** once opened, a descriptor stays attached
211
+ to the selected object. Renaming a path does not make the reader follow the new
212
+ name and does not necessarily cause an error. Already-opened directories can
213
+ also be moved; traversal is not confinement within a pathname boundary.
214
+ - **Other redirection and writers:** malicious regular files, hard links, bind
215
+ mounts, mount changes, privileged attackers, and other processes with write
216
+ access are outside the symlink guarantee. Protect files and their directories
217
+ according to your threat model. This library is not a sandbox or `chroot`.
218
+ - **Platform and I/O:** Windows is unsupported. Filesystem behavior still matters;
219
+ nonblocking open avoids the usual FIFO-open wait but does not guarantee a time
220
+ limit on file operations or safe reads from arbitrary devices. Keep the default
221
+ regular-file requirement unless you explicitly handle other file types.
222
+
223
+ See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
224
+
225
+ ## Related work
226
+
227
+ Safe path handling predates this project. Other approaches include
228
+ [`pathrs`](https://pypi.org/project/pathrs/) and
229
+ [`google/linkward`](https://github.com/google/linkward); their APIs, dependencies,
230
+ and confinement semantics differ. This project's focus is a small Python API for
231
+ rejecting every symlink component and checking the descriptor being read.
232
+
233
+ The `filelock` advisories
234
+ [CVE-2025-68146](https://github.com/tox-dev/filelock/security/advisories/GHSA-w853-jp5j-5j7f)
235
+ and [CVE-2026-22701](https://github.com/tox-dev/filelock/security/advisories/GHSA-qmgc-5h2g-mvrw)
236
+ illustrate related symlink risks in lock-file creation and locking. `linkward` is
237
+ read-only; it does not implement those locking operations or replace their fixes.
238
+
239
+ ## Development
240
+
241
+ ```bash
242
+ git clone https://github.com/alohaaaaaaaaaaaaaa/linkward.git
243
+ cd linkward
244
+ python -m pip install --upgrade pip
245
+ python -m pip install -e '.[test]'
246
+ python -m pytest
247
+ ```
248
+
249
+ The tests cover accepted reads as well as rejected paths and policies, controlled
250
+ concurrent changes, and descriptor cleanup.
251
+
252
+ ## License
253
+
254
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ linkward/__init__.py,sha256=6ZuYL6jA1ebsiKdgX7zJR-63mJOuzp9SGxx4ps8TsJY,693
2
+ linkward/core.py,sha256=hVyMUc5QO9va1YpWDb6qg0L4_LfHiMo09wLrSvJ_OOo,12668
3
+ linkward/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ linkward-0.1.0.dist-info/METADATA,sha256=HwMcNytxuDny07-canh76FefUdckd15ZIBq_4aeMblA,11146
5
+ linkward-0.1.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
6
+ linkward-0.1.0.dist-info/licenses/LICENSE,sha256=msluwQln86rse-NmyqkWZvY08nZWXrY4rJBwlMasRv4,1067
7
+ linkward-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elton Pang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.