neev 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.
neev/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from neev.cli import main as cli_main
2
+
3
+
4
+ def main() -> None:
5
+ """Entry point for the neev CLI."""
6
+ cli_main()
neev/auth.py ADDED
@@ -0,0 +1,274 @@
1
+ """Authentication and session management for neev."""
2
+
3
+ import base64
4
+ import hmac
5
+ import logging
6
+ import secrets
7
+ import threading
8
+ import time
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Sessions older than this are considered expired and will be pruned.
14
+ TOKEN_TTL = 86400 # 24 hours in seconds
15
+
16
+ COOKIE_NAME = "neev_session"
17
+
18
+ # Run opportunistic pruning every Nth validate() call.
19
+ _VALIDATE_PRUNE_INTERVAL = 100
20
+
21
+
22
+ # -- Credential validation ---------------------------------------------------
23
+
24
+
25
+ def check_basic_auth(
26
+ authorization_header: str | None,
27
+ expected_username: str,
28
+ expected_password: str,
29
+ ) -> bool:
30
+ """Validate a Basic Auth Authorization header against expected credentials.
31
+
32
+ Uses ``hmac.compare_digest()`` for constant-time comparison to prevent
33
+ timing attacks.
34
+
35
+ Args:
36
+ authorization_header: The raw ``Authorization`` header value, or
37
+ ``None`` if the header was not sent.
38
+ expected_username: The username to match against.
39
+ expected_password: The password to match against.
40
+
41
+ Returns:
42
+ ``True`` if credentials match, ``False`` otherwise.
43
+ """
44
+ if not authorization_header:
45
+ return False
46
+
47
+ if not authorization_header.startswith("Basic "):
48
+ return False
49
+
50
+ encoded = authorization_header[len("Basic ") :]
51
+
52
+ try:
53
+ decoded = base64.b64decode(encoded).decode("utf-8")
54
+ except (ValueError, UnicodeDecodeError):
55
+ logger.debug("Failed to decode Basic Auth credentials")
56
+ return False
57
+
58
+ if ":" not in decoded:
59
+ return False
60
+
61
+ username, password = decoded.split(":", maxsplit=1)
62
+ return check_credentials(username, password, expected_username, expected_password)
63
+
64
+
65
+ def check_credentials(
66
+ username: str,
67
+ password: str,
68
+ expected_username: str,
69
+ expected_password: str,
70
+ ) -> bool:
71
+ """Validate plaintext credentials against expected values.
72
+
73
+ Uses ``hmac.compare_digest()`` for constant-time comparison.
74
+
75
+ Args:
76
+ username: The submitted username.
77
+ password: The submitted password.
78
+ expected_username: The username to match against.
79
+ expected_password: The password to match against.
80
+
81
+ Returns:
82
+ ``True`` if both match, ``False`` otherwise.
83
+ """
84
+ username_match = hmac.compare_digest(
85
+ username.encode("utf-8"), expected_username.encode("utf-8")
86
+ )
87
+ password_match = hmac.compare_digest(
88
+ password.encode("utf-8"), expected_password.encode("utf-8")
89
+ )
90
+ return username_match and password_match
91
+
92
+
93
+ # -- Session management ------------------------------------------------------
94
+
95
+
96
+ class SessionStore:
97
+ """In-memory session token store with TTL-based expiry.
98
+
99
+ Each token is stored alongside its creation timestamp. Expired tokens are
100
+ pruned on every ``create()`` call. ``validate()`` also rejects tokens that
101
+ have exceeded ``TOKEN_TTL`` without waiting for the next prune sweep.
102
+
103
+ Tokens are generated with ``secrets.token_urlsafe()`` for cryptographic
104
+ randomness.
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ """Initialize an empty session store."""
109
+ self._lock = threading.Lock()
110
+ # Maps token → monotonic creation timestamp.
111
+ self._tokens: dict[str, float] = {}
112
+ self._validate_count = 0
113
+
114
+ def create(self) -> str:
115
+ """Create a new session token, pruning expired tokens first.
116
+
117
+ Returns:
118
+ A cryptographically random URL-safe token.
119
+ """
120
+ with self._lock:
121
+ self._prune()
122
+ token = secrets.token_urlsafe(32)
123
+ self._tokens[token] = time.monotonic()
124
+ return token
125
+
126
+ def validate(self, token: str) -> bool:
127
+ """Check whether a session token is valid and unexpired.
128
+
129
+ Args:
130
+ token: The token to validate.
131
+
132
+ Returns:
133
+ ``True`` if the token exists and has not exceeded ``TOKEN_TTL``.
134
+ """
135
+ with self._lock:
136
+ self._validate_count += 1
137
+ if self._validate_count % _VALIDATE_PRUNE_INTERVAL == 0:
138
+ self._prune()
139
+ created_at = self._tokens.get(token)
140
+ if created_at is None:
141
+ return False
142
+ return (time.monotonic() - created_at) < TOKEN_TTL
143
+
144
+ def invalidate(self, token: str) -> None:
145
+ """Remove a session token.
146
+
147
+ Args:
148
+ token: The token to invalidate.
149
+ """
150
+ with self._lock:
151
+ self._tokens.pop(token, None)
152
+ self._prune()
153
+
154
+ def _prune(self) -> None:
155
+ """Remove all tokens that have exceeded ``TOKEN_TTL``."""
156
+ now = time.monotonic()
157
+ expired = [t for t, ts in self._tokens.items() if (now - ts) >= TOKEN_TTL]
158
+ for token in expired:
159
+ del self._tokens[token]
160
+ if expired:
161
+ logger.debug("Pruned %d expired session token(s)", len(expired))
162
+
163
+
164
+ # -- Login rate limiting -----------------------------------------------------
165
+
166
+ # After this many consecutive failures, start blocking.
167
+ MAX_LOGIN_ATTEMPTS = 5
168
+
169
+ # Initial cooldown in seconds; doubles on each subsequent failure.
170
+ BASE_COOLDOWN = 30
171
+
172
+ # Never block longer than this.
173
+ MAX_COOLDOWN = 300
174
+
175
+
176
+ class LoginRateLimiter:
177
+ """Per-IP login rate limiter with exponential backoff.
178
+
179
+ Tracks consecutive failed login attempts per client IP. After
180
+ ``MAX_LOGIN_ATTEMPTS`` failures, further attempts are blocked for a
181
+ cooldown that doubles with each additional failure, up to
182
+ ``MAX_COOLDOWN`` seconds.
183
+
184
+ Thread-safe — uses the same locking pattern as ``SessionStore``.
185
+ """
186
+
187
+ def __init__(self) -> None:
188
+ """Initialize an empty rate limiter."""
189
+ self._lock = threading.Lock()
190
+ # Maps IP → (consecutive_failures, last_failure_monotonic).
191
+ self._attempts: dict[str, tuple[int, float]] = {}
192
+
193
+ def is_blocked(self, ip: str) -> bool:
194
+ """Check whether an IP is currently in a cooldown period.
195
+
196
+ Args:
197
+ ip: The client IP address.
198
+
199
+ Returns:
200
+ ``True`` if the IP must wait before retrying.
201
+ """
202
+ with self._lock:
203
+ record = self._attempts.get(ip)
204
+ if record is None:
205
+ return False
206
+ failures, last_failure = record
207
+ if failures < MAX_LOGIN_ATTEMPTS:
208
+ return False
209
+ cooldown = min(
210
+ BASE_COOLDOWN * 2 ** (failures - MAX_LOGIN_ATTEMPTS),
211
+ MAX_COOLDOWN,
212
+ )
213
+ return (time.monotonic() - last_failure) < cooldown
214
+
215
+ def record_failure(self, ip: str) -> None:
216
+ """Record a failed login attempt for an IP.
217
+
218
+ Args:
219
+ ip: The client IP address.
220
+ """
221
+ now = time.monotonic()
222
+ with self._lock:
223
+ self._prune(now)
224
+ record = self._attempts.get(ip)
225
+ failures = (record[0] + 1) if record else 1
226
+ self._attempts[ip] = (failures, now)
227
+ logger.warning("Failed login attempt %d from %s", failures, ip)
228
+
229
+ def _prune(self, now: float) -> None:
230
+ """Drop entries whose cooldown has fully elapsed.
231
+
232
+ Caller must hold ``self._lock``.
233
+ """
234
+ expired = [ip for ip, rec in self._attempts.items() if (now - rec[1]) >= MAX_COOLDOWN]
235
+ for ip in expired:
236
+ del self._attempts[ip]
237
+ if expired:
238
+ logger.debug("Pruned %d stale rate-limit entries", len(expired))
239
+
240
+ def record_success(self, ip: str) -> None:
241
+ """Clear the failure record for an IP after a successful login.
242
+
243
+ Args:
244
+ ip: The client IP address.
245
+ """
246
+ with self._lock:
247
+ self._attempts.pop(ip, None)
248
+
249
+
250
+ # -- Cookie helpers ----------------------------------------------------------
251
+
252
+
253
+ def parse_cookie(cookie_header: str | None, name: str) -> str | None:
254
+ """Extract a named value from a Cookie header.
255
+
256
+ Args:
257
+ cookie_header: The raw ``Cookie`` header value, or ``None``.
258
+ name: The cookie name to look for.
259
+
260
+ Returns:
261
+ The cookie value, or ``None`` if not found.
262
+ """
263
+ if not cookie_header:
264
+ return None
265
+
266
+ for raw_pair in cookie_header.split(";"):
267
+ stripped = raw_pair.strip()
268
+ if "=" not in stripped:
269
+ continue
270
+ key, value = stripped.split("=", maxsplit=1)
271
+ if key.strip() == name:
272
+ return value.strip()
273
+
274
+ return None
neev/cli.py ADDED
@@ -0,0 +1,309 @@
1
+ """CLI argument parsing and entry point for neev."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from neev.config import Config
10
+ from neev.log import ansi_styled
11
+ from neev.server import run_server
12
+ from neev.toml_config import load_toml, merge_toml_into_args
13
+
14
+
15
+ # Real defaults live here (not on the parser). The parser uses ``None`` as a
16
+ # sentinel so CLI-vs-TOML precedence can be resolved unambiguously: if an
17
+ # attribute is ``None`` after parsing, the user did not pass that flag, and
18
+ # TOML (or these defaults) may fill it in.
19
+ _DEFAULTS = {
20
+ "host": "127.0.0.1",
21
+ "port": 8000,
22
+ "show_hidden": False,
23
+ "enable_zip_download": False,
24
+ "max_zip_size": 100,
25
+ "enable_upload": False,
26
+ "read_only": False,
27
+ }
28
+
29
+
30
+ def _print_error(message: str) -> None:
31
+ """Print a red error message to stderr.
32
+
33
+ Args:
34
+ message: The error description (without ``error:`` prefix).
35
+ """
36
+ if sys.stderr.isatty():
37
+ print(f"\033[31merror:\033[0m {message}", file=sys.stderr)
38
+ else:
39
+ print(f"error: {message}", file=sys.stderr)
40
+
41
+
42
+ def _on(label: str) -> str:
43
+ """Format an enabled feature label in green.
44
+
45
+ Args:
46
+ label: The enabled-state text (e.g. ``"enabled"``).
47
+
48
+ Returns:
49
+ Green-styled text.
50
+ """
51
+ return ansi_styled(label, "32", stream=sys.stdout)
52
+
53
+
54
+ def _off(label: str) -> str:
55
+ """Format a disabled feature label in dim gray.
56
+
57
+ Args:
58
+ label: The disabled-state text (e.g. ``"disabled"``).
59
+
60
+ Returns:
61
+ Dim-styled text.
62
+ """
63
+ return ansi_styled(label, "2", stream=sys.stdout)
64
+
65
+
66
+ # -- Parsing and validation ------------------------------------------------
67
+
68
+
69
+ def _parse_auth(auth_string: str) -> tuple[str, str]:
70
+ """Split an ``user:pass`` string into username and password.
71
+
72
+ Args:
73
+ auth_string: Credentials in ``user:pass`` format.
74
+
75
+ Returns:
76
+ A ``(username, password)`` tuple.
77
+
78
+ Raises:
79
+ SystemExit: If the string does not contain exactly one colon.
80
+ """
81
+ if ":" not in auth_string:
82
+ _print_error(f"invalid auth format '{auth_string}' — expected 'user:pass'")
83
+ raise SystemExit(1)
84
+ username, password = auth_string.split(":", maxsplit=1)
85
+ return username, password
86
+
87
+
88
+ def _resolve_auth(args: argparse.Namespace) -> tuple[str | None, str | None]:
89
+ """Resolve auth credentials from CLI flag or environment variable.
90
+
91
+ ``--auth`` takes precedence over the ``NEEV_AUTH`` environment variable.
92
+
93
+ Args:
94
+ args: Parsed CLI arguments.
95
+
96
+ Returns:
97
+ A ``(username, password)`` tuple, or ``(None, None)`` if no auth is configured.
98
+ """
99
+ auth_string = args.auth or os.environ.get("NEEV_AUTH")
100
+ if not auth_string:
101
+ return None, None
102
+ return _parse_auth(auth_string)
103
+
104
+
105
+ def _validate_directory(directory: Path) -> Path:
106
+ """Resolve and validate the served directory.
107
+
108
+ Args:
109
+ directory: Path provided by the user (may be relative).
110
+
111
+ Returns:
112
+ The resolved absolute path.
113
+
114
+ Raises:
115
+ SystemExit: If the directory does not exist or is not a directory.
116
+ """
117
+ resolved = directory.resolve()
118
+ if not resolved.exists():
119
+ _print_error(f"directory '{directory}' does not exist")
120
+ raise SystemExit(1)
121
+ if not resolved.is_dir():
122
+ _print_error(f"'{directory}' is not a directory")
123
+ raise SystemExit(1)
124
+ return resolved
125
+
126
+
127
+ def _validate_port(value: str) -> int:
128
+ """Validate and convert a port string to an integer.
129
+
130
+ Args:
131
+ value: The raw string from argparse.
132
+
133
+ Returns:
134
+ The port number as an integer.
135
+
136
+ Raises:
137
+ argparse.ArgumentTypeError: If the value is not a valid port (1-65535).
138
+ """
139
+ try:
140
+ port = int(value)
141
+ except ValueError:
142
+ msg = f"'{value}' is not a valid port number"
143
+ raise argparse.ArgumentTypeError(msg) from None
144
+ if port < 1 or port > 65535:
145
+ msg = f"port must be between 1 and 65535, got {port}"
146
+ raise argparse.ArgumentTypeError(msg)
147
+ return port
148
+
149
+
150
+ def _build_parser() -> argparse.ArgumentParser:
151
+ """Build the argparse parser with all neev CLI flags.
152
+
153
+ All optional flags default to ``None`` so a caller can distinguish "not
154
+ passed" from "passed with a value that happens to match the default".
155
+ Real defaults are applied in :func:`build_config` after TOML merging.
156
+
157
+ Returns:
158
+ A configured ``ArgumentParser``.
159
+ """
160
+ parser = argparse.ArgumentParser(
161
+ prog="neev",
162
+ description="Serve a local directory over HTTP with auth, file browsing, and downloads.",
163
+ )
164
+ parser.add_argument(
165
+ "directory",
166
+ nargs="?",
167
+ default=".",
168
+ type=Path,
169
+ help="directory to serve (default: current directory)",
170
+ )
171
+ parser.add_argument(
172
+ "--host",
173
+ default=None,
174
+ help="bind address (default: 127.0.0.1)",
175
+ )
176
+ parser.add_argument(
177
+ "--port",
178
+ "-p",
179
+ default=None,
180
+ type=_validate_port,
181
+ help="bind port (default: 8000)",
182
+ )
183
+ parser.add_argument(
184
+ "--auth",
185
+ default=None,
186
+ help="credentials as 'user:pass' (or set NEEV_AUTH env var)",
187
+ )
188
+ parser.add_argument(
189
+ "--show-hidden",
190
+ action=argparse.BooleanOptionalAction,
191
+ default=None,
192
+ help="show dotfiles, dotdirs, and neev.toml in listings",
193
+ )
194
+ parser.add_argument(
195
+ "--enable-zip-download",
196
+ action=argparse.BooleanOptionalAction,
197
+ default=None,
198
+ help="allow ZIP downloads of folders",
199
+ )
200
+ parser.add_argument(
201
+ "--max-zip-size",
202
+ default=None,
203
+ type=int,
204
+ help="maximum ZIP archive size in MB (default: 100)",
205
+ )
206
+ parser.add_argument(
207
+ "--enable-upload",
208
+ action=argparse.BooleanOptionalAction,
209
+ default=None,
210
+ help="allow file uploads",
211
+ )
212
+ parser.add_argument(
213
+ "--read-only",
214
+ action=argparse.BooleanOptionalAction,
215
+ default=None,
216
+ help="disable all write operations (overrides --enable-upload)",
217
+ )
218
+ parser.add_argument(
219
+ "--banner",
220
+ default=None,
221
+ help="message to display at the top of directory listings",
222
+ )
223
+ return parser
224
+
225
+
226
+ def _print_startup_banner(config: Config) -> None:
227
+ """Print a human-readable summary of the resolved configuration.
228
+
229
+ Args:
230
+ config: The resolved server configuration.
231
+ """
232
+ url = ansi_styled(f"http://{config.host}:{config.port}", "1;36", stream=sys.stdout)
233
+ directory = ansi_styled(str(config.directory), "1", stream=sys.stdout)
234
+ serving = ansi_styled("Serving", "1;36", stream=sys.stdout)
235
+
236
+ auth_status = _on(f"enabled (user: {config.username})") if config.username else _off("disabled")
237
+ upload_status = _on("enabled") if config.enable_upload else _off("disabled")
238
+ zip_status = _on("enabled") if config.enable_zip_download else _off("disabled")
239
+ hidden_status = _on("visible") if config.show_hidden else _off("hidden")
240
+
241
+ print(f"{serving} {directory}")
242
+ print(f" {url}")
243
+ print()
244
+ print(f" auth: {auth_status}")
245
+ print(f" uploads: {upload_status}")
246
+ print(f" zip downloads: {zip_status}")
247
+ print(f" hidden files: {hidden_status}")
248
+ if config.banner:
249
+ print(f" banner: {_on(config.banner)}")
250
+
251
+
252
+ def _resolve(args: argparse.Namespace, attr: str) -> Any:
253
+ """Return ``args.<attr>`` if set, otherwise the registered default."""
254
+ value = getattr(args, attr)
255
+ if value is None:
256
+ return _DEFAULTS[attr]
257
+ return value
258
+
259
+
260
+ def build_config(args: argparse.Namespace, directory: Path) -> Config:
261
+ """Resolve and validate parsed CLI arguments into a ``Config``.
262
+
263
+ Applies real defaults to any attribute still set to ``None`` after TOML
264
+ merging, handles auth resolution (flag vs env var), and enforces
265
+ ``--read-only``.
266
+
267
+ Args:
268
+ args: Parsed CLI arguments (post-TOML-merge).
269
+ directory: The validated, resolved directory to serve.
270
+
271
+ Returns:
272
+ A frozen ``Config`` instance ready for use by the server.
273
+ """
274
+ username, password = _resolve_auth(args)
275
+
276
+ max_zip_size = _resolve(args, "max_zip_size")
277
+ if max_zip_size < 1:
278
+ _print_error("--max-zip-size must be at least 1 MB")
279
+ raise SystemExit(1)
280
+
281
+ enable_upload = _resolve(args, "enable_upload")
282
+ if _resolve(args, "read_only"):
283
+ enable_upload = False
284
+
285
+ return Config(
286
+ directory=directory,
287
+ host=_resolve(args, "host"),
288
+ port=_resolve(args, "port"),
289
+ username=username,
290
+ password=password,
291
+ show_hidden=_resolve(args, "show_hidden"),
292
+ enable_zip_download=_resolve(args, "enable_zip_download"),
293
+ max_zip_size=max_zip_size * 1024 * 1024,
294
+ enable_upload=enable_upload,
295
+ banner=args.banner,
296
+ )
297
+
298
+
299
+ def main() -> None:
300
+ """Entry point for the neev CLI."""
301
+ parser = _build_parser()
302
+ args = parser.parse_args()
303
+ directory = _validate_directory(args.directory)
304
+ toml_data = load_toml(directory)
305
+ if toml_data:
306
+ merge_toml_into_args(args, toml_data)
307
+ config = build_config(args, directory)
308
+ _print_startup_banner(config)
309
+ run_server(config)
neev/config.py ADDED
@@ -0,0 +1,52 @@
1
+ """Configuration dataclass for neev."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Config:
10
+ """Immutable configuration resolved from CLI arguments and environment.
11
+
12
+ All settings are finalized at parse time. For example, ``--read-only``
13
+ forces ``enable_upload`` to ``False`` before this object is created, so
14
+ consumers never need to check a separate read-only flag.
15
+
16
+ ``__post_init__`` normalizes ``directory`` to its real path (resolving
17
+ symlinks) and pre-computes ``auth_enabled`` so these are done once at
18
+ startup, not per-request.
19
+
20
+ Attributes:
21
+ directory: Absolute, realpath-resolved path to the served directory.
22
+ host: Network address to bind the server to.
23
+ port: TCP port to listen on.
24
+ username: HTTP Basic Auth username, or ``None`` if auth is disabled.
25
+ password: HTTP Basic Auth password, or ``None`` if auth is disabled.
26
+ show_hidden: Whether dotfiles and dotdirs appear in listings.
27
+ enable_zip_download: Whether on-the-fly ZIP downloads of folders are allowed.
28
+ max_zip_size: Maximum size in bytes for generated ZIP archives.
29
+ enable_upload: Whether file uploads are accepted.
30
+ auth_enabled: Whether HTTP Basic Auth is active (computed at init).
31
+ """
32
+
33
+ directory: Path
34
+ host: str
35
+ port: int
36
+ username: str | None
37
+ password: str | None
38
+ show_hidden: bool
39
+ enable_zip_download: bool
40
+ max_zip_size: int
41
+ enable_upload: bool
42
+ banner: str | None = None
43
+ auth_enabled: bool = False
44
+
45
+ def __post_init__(self) -> None:
46
+ """Resolve directory realpath and compute auth_enabled once."""
47
+ object.__setattr__(self, "directory", Path(os.path.realpath(self.directory)))
48
+ object.__setattr__(
49
+ self,
50
+ "auth_enabled",
51
+ self.username is not None and self.password is not None,
52
+ )