filekeeper-cli 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.
@@ -0,0 +1,236 @@
1
+ """Workspace-bound path resolution.
2
+
3
+ Every operation goes through :func:`resolve_path`. It is the single place where
4
+ "which file is this?" is decided, and it enforces four rules:
5
+
6
+ 1. Relative paths resolve against the workspace root; absolute paths are accepted
7
+ only when they land inside the workspace after canonical resolution.
8
+ 2. Relative paths containing a ``..`` segment are rejected outright with
9
+ :class:`PathOutsideWorkspaceError`. They are never needed for legitimate
10
+ workspace work, and rejecting them is cheaper to audit than proving each
11
+ normalisation correct.
12
+ 3. Symlinks are resolved. A link whose target leaves the workspace is refused
13
+ with :class:`SymlinkEscapeRefusedError`, and a symlink is never a write
14
+ target: the link itself would be replaced.
15
+ 4. An absolute path that resolves outside the workspace is refused with
16
+ :class:`AbsolutePathOutsideWorkspaceError`.
17
+ 5. Device files, sockets and FIFOs are refused for normal file operations, as are
18
+ paths inside Filekeeper's own state directory.
19
+
20
+ Errors name the caller's path, never the resolved host path, so adapter output
21
+ stays free of absolute paths.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import stat
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ from .errors import (
33
+ AbsolutePathOutsideWorkspaceError,
34
+ NotAFileError,
35
+ PathNotFoundError,
36
+ PathOutsideWorkspaceError,
37
+ SpecialFileRefusedError,
38
+ StatePathError,
39
+ SymlinkEscapeRefusedError,
40
+ SymlinkWriteRefusedError,
41
+ ValidationError,
42
+ )
43
+ from .errors import (
44
+ NotADirectoryError as FilekeeperNotADirectoryError,
45
+ )
46
+
47
+ PathKind = Literal["file", "directory", "symlink", "other"]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ResolvedPath:
52
+ """A path proven to be inside the workspace.
53
+
54
+ ``absolute`` is the canonical target (symlinks resolved) and is what
55
+ operations act on. ``link_path`` is the path as located in the workspace,
56
+ which differs only when the final component is a symlink; metadata calls need
57
+ it to stat the link itself instead of its target.
58
+ """
59
+
60
+ absolute: Path
61
+ relative: str
62
+ exists: bool
63
+ is_symlink: bool
64
+ link_path: Path
65
+
66
+ @property
67
+ def kind(self) -> PathKind:
68
+ if self.is_symlink:
69
+ return "symlink"
70
+ if self.absolute.is_dir():
71
+ return "directory"
72
+ if self.absolute.is_file():
73
+ return "file"
74
+ return "other"
75
+
76
+
77
+ def _reject_traversal(raw: str) -> None:
78
+ normalised = raw.replace("\\", "/")
79
+ for segment in normalised.split("/"):
80
+ if segment == "..":
81
+ raise PathOutsideWorkspaceError(
82
+ "path traversal with '..' is not allowed; use a workspace-relative path",
83
+ details={"path": raw},
84
+ )
85
+
86
+
87
+ def _kind_of(path: Path) -> PathKind:
88
+ if path.is_symlink():
89
+ return "symlink"
90
+ if path.is_dir():
91
+ return "directory"
92
+ if path.is_file():
93
+ return "file"
94
+ return "other"
95
+
96
+
97
+ def _escapes_through_symlink(joined: Path, root_canonical: Path) -> bool:
98
+ """True when a symlink at or below the workspace root points outside it."""
99
+
100
+ try:
101
+ relative = Path(os.path.abspath(joined)).relative_to(root_canonical)
102
+ except ValueError:
103
+ return False
104
+ current = root_canonical
105
+ for part in relative.parts:
106
+ current = current / part
107
+ if not current.is_symlink():
108
+ continue
109
+ target = Path(os.path.realpath(current))
110
+ if target != root_canonical and root_canonical not in target.parents:
111
+ return True
112
+ return False
113
+
114
+
115
+ def _reject_special_file(path: Path, *, display: str) -> None:
116
+ try:
117
+ info = os.lstat(path)
118
+ except FileNotFoundError:
119
+ return
120
+ mode = info.st_mode
121
+ if stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode):
122
+ return
123
+ raise SpecialFileRefusedError(
124
+ "only regular files and directories are supported; device files, sockets "
125
+ "and FIFOs are refused",
126
+ details={"path": display},
127
+ )
128
+
129
+
130
+ def resolve_path(
131
+ root: Path,
132
+ user_path: str | Path,
133
+ *,
134
+ for_write: bool = False,
135
+ must_exist: bool = False,
136
+ expect: Literal["file", "directory"] | None = None,
137
+ state_dir: Path | None = None,
138
+ ) -> ResolvedPath:
139
+ """Resolve ``user_path`` inside ``root`` or raise a boundary error."""
140
+
141
+ root_canonical = Path(root).resolve()
142
+ if not root_canonical.is_dir():
143
+ raise ValidationError(
144
+ "workspace root does not exist or is not a directory",
145
+ details={"root": str(root)},
146
+ )
147
+
148
+ raw = str(user_path)
149
+ if "\x00" in raw:
150
+ raise ValidationError("path contains a NUL byte", details={"path": "<contains NUL>"})
151
+ if raw.strip() == "":
152
+ raise ValidationError("path must not be empty")
153
+
154
+ candidate = Path(raw)
155
+ if candidate.is_absolute():
156
+ joined = candidate
157
+ else:
158
+ _reject_traversal(raw)
159
+ joined = root_canonical / candidate
160
+
161
+ # realpath resolves symlinks in the existing prefix and is well-defined for
162
+ # paths that do not exist yet.
163
+ canonical = Path(os.path.realpath(joined))
164
+
165
+ if canonical != root_canonical and root_canonical not in canonical.parents:
166
+ if _escapes_through_symlink(joined, root_canonical):
167
+ raise SymlinkEscapeRefusedError(
168
+ "symlink target resolves outside the workspace",
169
+ details={"path": raw},
170
+ )
171
+ if candidate.is_absolute():
172
+ raise AbsolutePathOutsideWorkspaceError(
173
+ "absolute path resolves outside the workspace",
174
+ details={"path": raw},
175
+ )
176
+ raise PathOutsideWorkspaceError(
177
+ "path resolves outside the workspace",
178
+ details={"path": raw},
179
+ )
180
+
181
+ if state_dir is not None:
182
+ state_canonical = Path(os.path.realpath(state_dir))
183
+ if canonical == state_canonical or state_canonical in canonical.parents:
184
+ raise StatePathError(
185
+ "path points inside Filekeeper's own state directory",
186
+ details={"path": raw},
187
+ )
188
+
189
+ is_link = joined.is_symlink()
190
+ exists = os.path.lexists(joined)
191
+ link_location = Path(os.path.abspath(joined))
192
+
193
+ if is_link and for_write:
194
+ raise SymlinkWriteRefusedError(
195
+ "writes to symlinks are refused; write to the link target explicitly",
196
+ details={"path": raw},
197
+ )
198
+
199
+ if exists:
200
+ _reject_special_file(joined, display=raw)
201
+
202
+ reported = link_location if is_link else canonical
203
+ relative = reported.relative_to(root_canonical).as_posix()
204
+ resolved = ResolvedPath(
205
+ absolute=canonical,
206
+ relative=relative if relative != "." else "",
207
+ exists=exists and canonical.exists(),
208
+ is_symlink=is_link,
209
+ link_path=link_location,
210
+ )
211
+
212
+ if must_exist and not resolved.exists:
213
+ raise PathNotFoundError("path does not exist", details={"path": raw})
214
+
215
+ if expect is not None and resolved.exists:
216
+ kind = resolved.kind
217
+ if expect == "file" and kind not in {"file", "symlink"}:
218
+ raise NotAFileError("path is not a regular file", details={"path": raw})
219
+ if expect == "file" and kind == "symlink":
220
+ # A symlink that resolves to a file is acceptable for reads.
221
+ if not resolved.absolute.is_file():
222
+ raise NotAFileError("path is not a regular file", details={"path": raw})
223
+ if expect == "directory" and not resolved.absolute.is_dir():
224
+ raise FilekeeperNotADirectoryError("path is not a directory", details={"path": raw})
225
+
226
+ return resolved
227
+
228
+
229
+ def ensure_parent_directory(resolved: ResolvedPath) -> None:
230
+ """Raise a validation error when the destination's parent is missing."""
231
+
232
+ if not resolved.absolute.parent.is_dir():
233
+ raise ValidationError(
234
+ "parent directory does not exist; create it first",
235
+ details={"path": resolved.relative or "."},
236
+ )
@@ -0,0 +1,5 @@
1
+ """Safety primitives: atomic replacement, hashing, text rules, refusals."""
2
+
3
+ from . import atomic, hashing, sensitive, textdata, vcs
4
+
5
+ __all__ = ["atomic", "hashing", "sensitive", "textdata", "vcs"]
@@ -0,0 +1,129 @@
1
+ """The atomic single-file write primitive.
2
+
3
+ The guarantee: on return, the destination holds either its complete previous
4
+ content or the complete new content. Never a prefix, never a mixture. That holds
5
+ across process death because the visible change is a single ``rename`` within one
6
+ directory, and it holds across machine crash when ``fsync`` is enabled because
7
+ both the temporary file and the parent directory are flushed first.
8
+
9
+ Failure before the rename leaves the destination untouched and removes the
10
+ temporary file; the caller can retry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import os
17
+ import stat
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ from ..errors import StorageError
22
+ from .hashing import hash_bytes, hash_file
23
+
24
+ _TEMP_PREFIX = ".keeper-tmp-"
25
+
26
+
27
+ def _default_file_mode() -> int:
28
+ """The mode a plain ``open()`` would produce for a new file under this umask."""
29
+
30
+ current = os.umask(0)
31
+ os.umask(current)
32
+ return 0o666 & ~current
33
+
34
+
35
+ def _fsync_directory(directory: Path) -> None:
36
+ """Flush a directory entry. Unsupported on some platforms; that is not an error."""
37
+
38
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
39
+ try:
40
+ descriptor = os.open(directory, flags)
41
+ except OSError:
42
+ return
43
+ try:
44
+ os.fsync(descriptor)
45
+ except OSError: # pragma: no cover - e.g. filesystems that reject dir fsync
46
+ pass
47
+ finally:
48
+ os.close(descriptor)
49
+
50
+
51
+ def atomic_write_bytes(
52
+ destination: Path,
53
+ data: bytes,
54
+ *,
55
+ mode: int | None = None,
56
+ fsync: bool = True,
57
+ display_path: str | None = None,
58
+ ) -> str:
59
+ """Replace ``destination`` with ``data`` atomically; return the new content hash.
60
+
61
+ ``mode`` sets the resulting permission bits. When omitted, an existing
62
+ destination keeps its mode and a new file gets the umask-derived default.
63
+ ``display_path`` is the caller-facing path used in error details; the
64
+ resolved host path and the temporary name are never reported. The final
65
+ content is read back and hashed; a mismatch is a storage error.
66
+ """
67
+
68
+ details = {"path": display_path} if display_path is not None else {}
69
+
70
+ if destination.is_symlink():
71
+ raise StorageError(
72
+ "refusing to replace a symlink atomically; the link itself would be "
73
+ "replaced, not its target",
74
+ details=details,
75
+ )
76
+ parent = destination.parent
77
+ if not parent.is_dir():
78
+ raise StorageError("destination directory does not exist", details=details)
79
+
80
+ existing_mode: int | None = None
81
+ try:
82
+ existing_mode = stat.S_IMODE(destination.stat().st_mode)
83
+ except FileNotFoundError:
84
+ existing_mode = None
85
+
86
+ temporary_path: Path | None = None
87
+ try:
88
+ descriptor, temporary_name = tempfile.mkstemp(prefix=_TEMP_PREFIX, dir=parent)
89
+ temporary_path = Path(temporary_name)
90
+ with os.fdopen(descriptor, "wb") as handle:
91
+ handle.write(data)
92
+ handle.flush()
93
+ if fsync:
94
+ os.fsync(handle.fileno())
95
+ effective_mode = mode if mode is not None else existing_mode
96
+ if effective_mode is None:
97
+ # tempfile.mkstemp creates 0600; a new file should follow the umask.
98
+ effective_mode = _default_file_mode()
99
+ os.chmod(temporary_path, effective_mode)
100
+ os.replace(temporary_path, destination)
101
+ except OSError as exc:
102
+ # ``str(exc)`` carries the temporary path and errno context; only the
103
+ # standard reason phrase is safe to show a caller.
104
+ raise StorageError(
105
+ f"atomic write failed: {exc.strerror or type(exc).__name__}",
106
+ details=details,
107
+ ) from exc
108
+ finally:
109
+ if temporary_path is not None:
110
+ with contextlib.suppress(FileNotFoundError):
111
+ temporary_path.unlink()
112
+
113
+ if fsync:
114
+ _fsync_directory(parent)
115
+
116
+ expected = hash_bytes(data)
117
+ actual = hash_file(destination, display_path=display_path or destination.name)
118
+ if expected != actual:
119
+ raise StorageError(
120
+ "content verification failed after atomic write",
121
+ details={**details, "expected_hash": expected, "actual_hash": actual},
122
+ )
123
+ return actual
124
+
125
+
126
+ def fsync_directory(directory: Path) -> None:
127
+ """Public wrapper used after rename/unlink by non-atomic operations."""
128
+
129
+ _fsync_directory(directory)
@@ -0,0 +1,73 @@
1
+ """SHA-256 hashing helpers.
2
+
3
+ Hashes are always returned as ``sha256:<hex>``. Incoming hashes are accepted with
4
+ or without the prefix, and a malformed value is a validation error rather than a
5
+ silent mismatch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ from pathlib import Path
13
+
14
+ from ..errors import PathNotFoundError, ValidationError, storage_failure
15
+
16
+ ALGORITHM = "sha256"
17
+ PREFIX = f"{ALGORITHM}:"
18
+ CHUNK_SIZE = 1024 * 1024
19
+ _HEX_LENGTH = 64
20
+
21
+
22
+ def format_digest(digest_hex: str) -> str:
23
+ """Return the canonical ``sha256:<hex>`` form for a bare hex digest."""
24
+
25
+ return f"{PREFIX}{digest_hex}"
26
+
27
+
28
+ def hash_bytes(data: bytes) -> str:
29
+ return format_digest(hashlib.sha256(data).hexdigest())
30
+
31
+
32
+ def hash_file(path: Path, *, display_path: str | None = None) -> str:
33
+ """Stream a file through SHA-256; never loads the whole file into memory.
34
+
35
+ ``display_path`` is the caller-facing path used in error details. Callers that
36
+ hold a resolved path must pass the workspace-relative form; when it is
37
+ omitted only the file name is used, so a failure never reports the resolved
38
+ host path.
39
+ """
40
+
41
+ display = path.name if display_path is None else display_path
42
+ digest = hashlib.sha256()
43
+ try:
44
+ with path.open("rb") as handle:
45
+ while chunk := handle.read(CHUNK_SIZE):
46
+ digest.update(chunk)
47
+ except FileNotFoundError as exc:
48
+ raise PathNotFoundError("file not found", details={"path": display}) from exc
49
+ except OSError as exc:
50
+ raise storage_failure("hash", display, exc) from exc
51
+ return format_digest(digest.hexdigest())
52
+
53
+
54
+ def normalize_hash(value: str) -> str:
55
+ """Validate and canonicalise a caller-supplied hash."""
56
+
57
+ candidate = value.strip()
58
+ if candidate.startswith(PREFIX):
59
+ candidate = candidate[len(PREFIX) :]
60
+ valid_characters = set("0123456789abcdefABCDEF")
61
+ if len(candidate) != _HEX_LENGTH or any(char not in valid_characters for char in candidate):
62
+ raise ValidationError(
63
+ "not a SHA-256 hash; expected 64 hexadecimal characters, optionally "
64
+ "prefixed with 'sha256:'",
65
+ details={"value": value},
66
+ )
67
+ return format_digest(candidate.lower())
68
+
69
+
70
+ def hashes_match(left: str, right: str) -> bool:
71
+ """Constant-time comparison of two canonical hashes."""
72
+
73
+ return hmac.compare_digest(normalize_hash(left), normalize_hash(right))
@@ -0,0 +1,65 @@
1
+ """Refusal to write credential and key material.
2
+
3
+ A cheap guard on the most expensive mistake. The rule is a name-shape check on
4
+ the workspace-relative target: it refuses obvious credential files and anything
5
+ under a key directory. It is not a content scanner, and it is overridable only
6
+ through a Core argument and a CLI flag whose names state the risk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Final
12
+
13
+ SENSITIVE_FILENAMES: Final[frozenset[str]] = frozenset(
14
+ {
15
+ ".env",
16
+ "id_rsa",
17
+ "id_dsa",
18
+ "id_ecdsa",
19
+ "id_ed25519",
20
+ "authorized_keys",
21
+ "known_hosts",
22
+ ".netrc",
23
+ ".pgpass",
24
+ ".npmrc",
25
+ ".pypirc",
26
+ "credentials",
27
+ "credentials.json",
28
+ }
29
+ )
30
+ SENSITIVE_SUFFIXES: Final[tuple[str, ...]] = (
31
+ ".pem",
32
+ ".key",
33
+ ".p12",
34
+ ".pfx",
35
+ ".keystore",
36
+ ".jks",
37
+ )
38
+ SENSITIVE_DIRECTORIES: Final[frozenset[str]] = frozenset({".ssh", ".gnupg", ".aws"})
39
+ _ENV_PREFIX = ".env."
40
+
41
+
42
+ def sensitive_reason(relative_path: str) -> str | None:
43
+ """Return a short reason when the relative path looks like credential material."""
44
+
45
+ parts = [part for part in relative_path.replace("\\", "/").split("/") if part]
46
+ if not parts:
47
+ return None
48
+
49
+ for part in parts[:-1]:
50
+ if part.lower() in SENSITIVE_DIRECTORIES:
51
+ return f"inside the {part}/ directory"
52
+
53
+ name = parts[-1]
54
+ lowered = name.lower()
55
+ if lowered in SENSITIVE_FILENAMES:
56
+ return "credential file name"
57
+ if lowered.startswith(_ENV_PREFIX):
58
+ return "environment file name"
59
+ if lowered.endswith(SENSITIVE_SUFFIXES):
60
+ return "key or certificate file extension"
61
+ return None
62
+
63
+
64
+ def is_sensitive_path(relative_path: str) -> bool:
65
+ return sensitive_reason(relative_path) is not None
@@ -0,0 +1,142 @@
1
+ """Text detection, decoding and encoding rules.
2
+
3
+ Rules this module enforces, and that the documentation repeats:
4
+
5
+ * A file containing a NUL byte in its first 8 KiB is treated as binary. Text
6
+ operations refuse it instead of returning mojibake.
7
+ * Decoding is strict. Undecodable bytes are an error unless the caller names an
8
+ encoding explicitly.
9
+ * A UTF-8 byte-order mark is preserved on rewrite.
10
+ * Line endings are *never* normalised. Content is decoded and re-encoded as-is;
11
+ the detected convention is reported, not applied.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import codecs
17
+ from dataclasses import dataclass
18
+ from typing import Final
19
+
20
+ from ..errors import BinaryContentRefusedError, ValidationError
21
+
22
+ BINARY_SAMPLE_SIZE: Final = 8192
23
+ _BOM_BYTES: Final[dict[str, bytes]] = {
24
+ "utf-8": codecs.BOM_UTF8,
25
+ "utf-16-le": codecs.BOM_UTF16_LE,
26
+ "utf-16-be": codecs.BOM_UTF16_BE,
27
+ "utf-32-le": codecs.BOM_UTF32_LE,
28
+ "utf-32-be": codecs.BOM_UTF32_BE,
29
+ }
30
+ _BOMS: Final[tuple[tuple[bytes, str], ...]] = (
31
+ (codecs.BOM_UTF8, "utf-8"),
32
+ (codecs.BOM_UTF16_LE, "utf-16-le"),
33
+ (codecs.BOM_UTF16_BE, "utf-16-be"),
34
+ (codecs.BOM_UTF32_LE, "utf-32-le"),
35
+ (codecs.BOM_UTF32_BE, "utf-32-be"),
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class TextProfile:
41
+ """How a file's bytes decode, and what its lines look like."""
42
+
43
+ encoding: str
44
+ newline: str
45
+ has_bom: bool
46
+
47
+
48
+ def looks_binary(data: bytes) -> bool:
49
+ """True when the sample contains NUL, the cheapest reliable heuristic."""
50
+
51
+ return b"\x00" in data[:BINARY_SAMPLE_SIZE]
52
+
53
+
54
+ def detect_bom(data: bytes) -> tuple[str, bool] | None:
55
+ """Return ``(encoding, has_bom)`` when the data starts with a known BOM."""
56
+
57
+ # Longest first: UTF-32 LE shares its first two bytes with UTF-16 LE.
58
+ for bom, encoding in sorted(_BOMS, key=lambda item: len(item[0]), reverse=True):
59
+ if data.startswith(bom):
60
+ return encoding, True
61
+ return None
62
+
63
+
64
+ def detect_newline(text: str) -> str:
65
+ """Dominant newline convention: ``\\r\\n``, ``\\n``, ``\\r``, or ``""``."""
66
+
67
+ crlf = text.count("\r\n")
68
+ lone_cr = text.count("\r") - crlf
69
+ lone_lf = text.count("\n") - crlf
70
+ counts = {"\r\n": crlf, "\n": lone_lf, "\r": lone_cr}
71
+ best = max(counts, key=lambda key: counts[key])
72
+ return best if counts[best] > 0 else ""
73
+
74
+
75
+ def decode_bytes(
76
+ data: bytes,
77
+ *,
78
+ encoding: str | None = None,
79
+ partial: bool = False,
80
+ path: str = "",
81
+ ) -> tuple[str, TextProfile]:
82
+ """Decode bytes as text, or refuse them with BINARY_CONTENT_REFUSED.
83
+
84
+ ``partial`` tolerates a truncated trailing multi-byte sequence, which is what
85
+ a bounded read produces.
86
+ """
87
+
88
+ if encoding is None:
89
+ if looks_binary(data):
90
+ raise BinaryContentRefusedError(
91
+ "binary content cannot be read as text",
92
+ details={"path": path, "reason": "nul_byte"},
93
+ )
94
+ bom = detect_bom(data)
95
+ if bom is not None:
96
+ detected, has_bom = bom
97
+ encoding = detected
98
+ else:
99
+ detected, has_bom = "utf-8", False
100
+ encoding = detected
101
+ else:
102
+ has_bom = encoding == "utf-8" and data.startswith(codecs.BOM_UTF8)
103
+
104
+ try:
105
+ decoder = codecs.getincrementaldecoder(encoding)(errors="strict")
106
+ text = decoder.decode(data, final=not partial)
107
+ except LookupError as exc:
108
+ raise ValidationError(
109
+ f"unknown text encoding: {encoding}", details={"encoding": encoding}
110
+ ) from exc
111
+ except UnicodeDecodeError as exc:
112
+ raise BinaryContentRefusedError(
113
+ f"content is not valid {encoding}; pass an explicit encoding to force a read",
114
+ details={"path": path, "encoding": encoding, "byte_offset": exc.start},
115
+ ) from exc
116
+
117
+ if encoding == "utf-8" and text.startswith("\ufeff"):
118
+ text = text[1:]
119
+ has_bom = True
120
+ elif encoding in {"utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be"} and text.startswith(
121
+ "\ufeff"
122
+ ):
123
+ text = text[1:]
124
+
125
+ return text, TextProfile(encoding=encoding, newline=detect_newline(text), has_bom=has_bom)
126
+
127
+
128
+ def encode_text(text: str, *, encoding: str, has_bom: bool) -> bytes:
129
+ """Encode text back with the same convention it was read with."""
130
+
131
+ try:
132
+ payload = text.encode(encoding)
133
+ except (LookupError, UnicodeEncodeError) as exc:
134
+ raise ValidationError(
135
+ f"content cannot be encoded as {encoding}: {exc}",
136
+ details={"encoding": encoding},
137
+ ) from exc
138
+ if has_bom:
139
+ bom = _BOM_BYTES.get(encoding)
140
+ if bom is not None:
141
+ return bom + payload
142
+ return payload