ssh-docs 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.
- ssh_docs/__init__.py +19 -0
- ssh_docs/__main__.py +6 -0
- ssh_docs/cli.py +392 -0
- ssh_docs/config.py +251 -0
- ssh_docs/server.py +234 -0
- ssh_docs/shell.py +307 -0
- ssh_docs-0.1.0.dist-info/METADATA +392 -0
- ssh_docs-0.1.0.dist-info/RECORD +11 -0
- ssh_docs-0.1.0.dist-info/WHEEL +5 -0
- ssh_docs-0.1.0.dist-info/entry_points.txt +2 -0
- ssh_docs-0.1.0.dist-info/top_level.txt +1 -0
ssh_docs/server.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""SSH server implementation for SSH-Docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import asyncssh
|
|
11
|
+
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .shell import SSHDocsShell
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SSHDocsServer:
|
|
20
|
+
"""SSH server that provides documentation browsing capabilities."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Config) -> None:
|
|
23
|
+
self.config = config
|
|
24
|
+
self.content_root = config.content_root.resolve()
|
|
25
|
+
self.server: Optional[asyncssh.SSHAcceptor] = None
|
|
26
|
+
|
|
27
|
+
# Ensure content root exists
|
|
28
|
+
if not self.content_root.exists():
|
|
29
|
+
raise ValueError(f"Content root does not exist: {self.content_root}")
|
|
30
|
+
if not self.content_root.is_dir():
|
|
31
|
+
raise ValueError(f"Content root is not a directory: {self.content_root}")
|
|
32
|
+
|
|
33
|
+
async def start(self) -> None:
|
|
34
|
+
"""Start the SSH server."""
|
|
35
|
+
# Generate or load host key
|
|
36
|
+
host_key = await self._get_host_key()
|
|
37
|
+
|
|
38
|
+
# Configure server options
|
|
39
|
+
server_options = {
|
|
40
|
+
"server_host_keys": [host_key],
|
|
41
|
+
"process_factory": self._create_process,
|
|
42
|
+
"session_factory": None, # We handle sessions via process_factory
|
|
43
|
+
"encoding": None, # Handle encoding in shell
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Add authentication if configured
|
|
47
|
+
if self.config.auth_type == "password" and self.config.password:
|
|
48
|
+
server_options["password_auth"] = True
|
|
49
|
+
|
|
50
|
+
elif self.config.auth_type == "key" and self.config.authorized_keys:
|
|
51
|
+
server_options["authorized_client_keys"] = self.config.authorized_keys
|
|
52
|
+
|
|
53
|
+
else:
|
|
54
|
+
# Public access - accept any connection
|
|
55
|
+
server_options["password_auth"] = False
|
|
56
|
+
server_options["public_key_auth"] = False
|
|
57
|
+
|
|
58
|
+
logger.info(f"Starting SSH server on {self.config.host}:{self.config.port}")
|
|
59
|
+
logger.info(f"Content root: {self.content_root}")
|
|
60
|
+
logger.info(f"Site name: {self.config.site_name}")
|
|
61
|
+
logger.info(f"Auth type: {self.config.auth_type}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
self.server = await asyncssh.create_server(
|
|
65
|
+
lambda: SSHDocsServerProtocol(self),
|
|
66
|
+
host=self.config.host,
|
|
67
|
+
port=self.config.port,
|
|
68
|
+
**server_options,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger.info("SSH server started successfully")
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to start SSH server: {e}")
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
async def stop(self) -> None:
|
|
78
|
+
"""Stop the SSH server."""
|
|
79
|
+
if self.server:
|
|
80
|
+
self.server.close()
|
|
81
|
+
await self.server.wait_closed()
|
|
82
|
+
logger.info("SSH server stopped")
|
|
83
|
+
|
|
84
|
+
def _create_process(self) -> SSHDocsProcess:
|
|
85
|
+
"""Factory method to create a new process for each connection."""
|
|
86
|
+
return SSHDocsProcess(self)
|
|
87
|
+
|
|
88
|
+
async def _get_host_key(self) -> str:
|
|
89
|
+
"""Get or generate SSH host key."""
|
|
90
|
+
if self.config.host_key and self.config.host_key.exists():
|
|
91
|
+
logger.info(f"Using host key from: {self.config.host_key}")
|
|
92
|
+
return str(self.config.host_key)
|
|
93
|
+
|
|
94
|
+
# Generate temporary key
|
|
95
|
+
logger.info("Generating temporary host key")
|
|
96
|
+
key = asyncssh.generate_private_key("ssh-rsa")
|
|
97
|
+
|
|
98
|
+
# Save to default location if possible
|
|
99
|
+
key_dir = Path.home() / ".ssh-docs" / "keys"
|
|
100
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
key_path = key_dir / "ssh_host_rsa_key"
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
key.write_private_key(str(key_path))
|
|
105
|
+
logger.info(f"Saved host key to: {key_path}")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning(f"Could not save host key: {e}")
|
|
108
|
+
|
|
109
|
+
return key
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SSHDocsServerProtocol(asyncssh.SSHServer):
|
|
113
|
+
"""SSH server protocol handler."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, server: SSHDocsServer) -> None:
|
|
116
|
+
self.server = server
|
|
117
|
+
|
|
118
|
+
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
|
119
|
+
"""Called when a connection is established."""
|
|
120
|
+
peer = conn.get_extra_info("peername")
|
|
121
|
+
logger.info(f"Connection from {peer}")
|
|
122
|
+
|
|
123
|
+
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
124
|
+
"""Called when connection is lost."""
|
|
125
|
+
if exc:
|
|
126
|
+
logger.error(f"Connection lost with error: {exc}")
|
|
127
|
+
else:
|
|
128
|
+
logger.info("Connection closed")
|
|
129
|
+
|
|
130
|
+
def begin_auth(self, username: str) -> bool:
|
|
131
|
+
"""Begin authentication for a user."""
|
|
132
|
+
logger.debug(f"Authentication attempt for user: {username}")
|
|
133
|
+
|
|
134
|
+
# For public access, accept any username
|
|
135
|
+
if self.server.config.auth_type == "public":
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def password_auth_supported(self) -> bool:
|
|
141
|
+
"""Check if password authentication is supported."""
|
|
142
|
+
return self.server.config.auth_type == "password"
|
|
143
|
+
|
|
144
|
+
def validate_password(self, username: str, password: str) -> bool:
|
|
145
|
+
"""Validate password for a user."""
|
|
146
|
+
if self.server.config.auth_type != "password":
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if not self.server.config.password:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
return password == self.server.config.password
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SSHDocsProcess(asyncssh.SSHServerProcess):
|
|
156
|
+
"""Process handler for SSH sessions."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, server: SSHDocsServer) -> None:
|
|
159
|
+
self.server = server
|
|
160
|
+
self.shell: Optional[SSHDocsShell] = None
|
|
161
|
+
|
|
162
|
+
def connection_made(self, chan: asyncssh.SSHServerChannel) -> None:
|
|
163
|
+
"""Called when channel is opened."""
|
|
164
|
+
self.chan = chan
|
|
165
|
+
|
|
166
|
+
def shell_requested(self) -> bool:
|
|
167
|
+
"""Handle shell request."""
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def session_started(self) -> None:
|
|
171
|
+
"""Called when session starts."""
|
|
172
|
+
# Create shell instance
|
|
173
|
+
self.shell = SSHDocsShell(
|
|
174
|
+
stdin=self.stdin,
|
|
175
|
+
stdout=self.stdout,
|
|
176
|
+
stderr=self.stderr,
|
|
177
|
+
content_root=self.server.content_root,
|
|
178
|
+
site_name=self.server.config.site_name,
|
|
179
|
+
banner=self.server.config.banner,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Start shell in background task
|
|
183
|
+
asyncio.create_task(self._run_shell())
|
|
184
|
+
|
|
185
|
+
async def _run_shell(self) -> None:
|
|
186
|
+
"""Run the shell session."""
|
|
187
|
+
try:
|
|
188
|
+
await self.shell.run()
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Shell error: {e}")
|
|
191
|
+
finally:
|
|
192
|
+
self.exit(0)
|
|
193
|
+
|
|
194
|
+
def break_received(self, msec: int) -> bool:
|
|
195
|
+
"""Handle break signal."""
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
def signal_received(self, signal: str) -> None:
|
|
199
|
+
"""Handle signal."""
|
|
200
|
+
logger.debug(f"Received signal: {signal}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def run_server(config: Config) -> None:
|
|
204
|
+
"""Run the SSH server (convenience function).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
config: Server configuration.
|
|
208
|
+
"""
|
|
209
|
+
# Setup logging
|
|
210
|
+
log_level = getattr(logging, config.log_level.upper(), logging.INFO)
|
|
211
|
+
logging.basicConfig(
|
|
212
|
+
level=log_level,
|
|
213
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
214
|
+
filename=config.log_file if config.log_file else None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
server = SSHDocsServer(config)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
await server.start()
|
|
221
|
+
|
|
222
|
+
# Keep server running
|
|
223
|
+
print(f"SSH-Docs server running on {config.host}:{config.port}")
|
|
224
|
+
print(f"Content: {config.content_root}")
|
|
225
|
+
print(f"Connect with: ssh {config.host} -p {config.port}")
|
|
226
|
+
print("Press Ctrl+C to stop")
|
|
227
|
+
|
|
228
|
+
# Wait indefinitely
|
|
229
|
+
await asyncio.Event().wait()
|
|
230
|
+
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
print("\nShutting down...")
|
|
233
|
+
finally:
|
|
234
|
+
await server.stop()
|
ssh_docs/shell.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Interactive shell session for SSH-Docs server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SSHDocsShell:
|
|
13
|
+
"""Interactive shell session that provides Unix-like commands for browsing documentation."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
stdin: Any,
|
|
18
|
+
stdout: Any,
|
|
19
|
+
stderr: Any,
|
|
20
|
+
content_root: Path,
|
|
21
|
+
site_name: str,
|
|
22
|
+
banner: Optional[str] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.stdin = stdin
|
|
25
|
+
self.stdout = stdout
|
|
26
|
+
self.stderr = stderr
|
|
27
|
+
self.content_root = content_root.resolve()
|
|
28
|
+
self.site_name = site_name
|
|
29
|
+
self.cwd = "/site"
|
|
30
|
+
self.banner = banner or self._default_banner()
|
|
31
|
+
|
|
32
|
+
def _default_banner(self) -> str:
|
|
33
|
+
return f"""Connected to {self.site_name}.ssh-docs
|
|
34
|
+
Source root: {self.content_root}
|
|
35
|
+
Mounted content: /site
|
|
36
|
+
Supported commands: pwd, ls, cd, cat, head, tail, find, grep, help, exit
|
|
37
|
+
Readonly session
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
async def run(self) -> None:
|
|
42
|
+
"""Main command loop."""
|
|
43
|
+
self.stdout.write(self.banner)
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
self.stdout.write(f"{self.cwd}$ ")
|
|
48
|
+
line = await self.stdin.readline()
|
|
49
|
+
if not line:
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
raw = line.strip()
|
|
53
|
+
if not raw:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parts = shlex.split(raw)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
self.stdout.write(f"parse error: {exc}\n")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
cmd = parts[0]
|
|
63
|
+
args = parts[1:]
|
|
64
|
+
|
|
65
|
+
if cmd in {"exit", "quit"}:
|
|
66
|
+
self.stdout.write("Session closed\n")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
await self._execute_command(cmd, args)
|
|
70
|
+
|
|
71
|
+
except (EOFError, KeyboardInterrupt):
|
|
72
|
+
self.stdout.write("\nSession closed\n")
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
async def _execute_command(self, cmd: str, args: list[str]) -> None:
|
|
76
|
+
"""Execute a single command."""
|
|
77
|
+
commands: dict[str, Callable] = {
|
|
78
|
+
"help": self.cmd_help,
|
|
79
|
+
"pwd": self.cmd_pwd,
|
|
80
|
+
"ls": self.cmd_ls,
|
|
81
|
+
"cd": self.cmd_cd,
|
|
82
|
+
"cat": self.cmd_cat,
|
|
83
|
+
"head": self.cmd_head,
|
|
84
|
+
"tail": self.cmd_tail,
|
|
85
|
+
"find": self.cmd_find,
|
|
86
|
+
"grep": self.cmd_grep,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if cmd in commands:
|
|
90
|
+
await commands[cmd](args)
|
|
91
|
+
else:
|
|
92
|
+
self.stdout.write(f"unsupported command: {cmd}\n")
|
|
93
|
+
|
|
94
|
+
def _resolve_virtual_path(self, value: Optional[str]) -> str:
|
|
95
|
+
"""Resolve a path argument to a normalized virtual path."""
|
|
96
|
+
if not value:
|
|
97
|
+
return self.cwd
|
|
98
|
+
candidate = value if value.startswith("/") else str(Path(self.cwd) / value)
|
|
99
|
+
normalized = os.path.normpath(candidate).replace("\\", "/")
|
|
100
|
+
if not normalized.startswith("/site"):
|
|
101
|
+
return "/invalid"
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
def _to_real_path(self, virtual_path: str) -> Optional[Path]:
|
|
105
|
+
"""Convert virtual path to real filesystem path with security checks."""
|
|
106
|
+
if not virtual_path.startswith("/site"):
|
|
107
|
+
return None
|
|
108
|
+
rel = virtual_path.removeprefix("/site").lstrip("/")
|
|
109
|
+
target = (self.content_root / rel).resolve()
|
|
110
|
+
try:
|
|
111
|
+
target.relative_to(self.content_root)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|
|
114
|
+
return target
|
|
115
|
+
|
|
116
|
+
def _to_virtual_path(self, path: Path) -> str:
|
|
117
|
+
"""Convert real filesystem path to virtual path."""
|
|
118
|
+
rel = path.relative_to(self.content_root)
|
|
119
|
+
rel_str = str(rel).replace("\\", "/")
|
|
120
|
+
return "/site" if rel_str == "." else f"/site/{rel_str}"
|
|
121
|
+
|
|
122
|
+
async def cmd_help(self, args: list[str]) -> None:
|
|
123
|
+
"""Display available commands."""
|
|
124
|
+
self.stdout.write("Commands: pwd, ls, cd, cat, head, tail, find, grep, help, exit\n")
|
|
125
|
+
|
|
126
|
+
async def cmd_pwd(self, args: list[str]) -> None:
|
|
127
|
+
"""Print current working directory."""
|
|
128
|
+
self.stdout.write(f"{self.cwd}\n")
|
|
129
|
+
|
|
130
|
+
async def cmd_ls(self, args: list[str]) -> None:
|
|
131
|
+
"""List directory contents."""
|
|
132
|
+
virtual_path = self._resolve_virtual_path(args[0] if args else None)
|
|
133
|
+
real_path = self._to_real_path(virtual_path)
|
|
134
|
+
|
|
135
|
+
if virtual_path == "/invalid" or real_path is None or not real_path.exists():
|
|
136
|
+
self.stdout.write(f"ls: no such file or directory: {virtual_path}\n")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if real_path.is_file():
|
|
140
|
+
self.stdout.write(f"{real_path.name}\n")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for child in sorted(real_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
144
|
+
self.stdout.write(f"{child.name}\n")
|
|
145
|
+
|
|
146
|
+
async def cmd_cd(self, args: list[str]) -> None:
|
|
147
|
+
"""Change current directory."""
|
|
148
|
+
virtual_path = self._resolve_virtual_path(args[0] if args else "/site")
|
|
149
|
+
real_path = self._to_real_path(virtual_path)
|
|
150
|
+
|
|
151
|
+
if virtual_path == "/invalid" or real_path is None or not real_path.exists():
|
|
152
|
+
self.stdout.write(f"cd: no such file or directory: {virtual_path}\n")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if not real_path.is_dir():
|
|
156
|
+
self.stdout.write(f"cd: not a directory: {virtual_path}\n")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
self.cwd = virtual_path
|
|
160
|
+
|
|
161
|
+
async def cmd_cat(self, args: list[str]) -> None:
|
|
162
|
+
"""Display file contents."""
|
|
163
|
+
file_path = await self._require_file_arg("cat", args)
|
|
164
|
+
if file_path is None:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
content = file_path.read_text(encoding="utf-8")
|
|
169
|
+
self.stdout.write(content.rstrip() + "\n")
|
|
170
|
+
except UnicodeDecodeError:
|
|
171
|
+
self.stdout.write("cat: cannot read binary file\n")
|
|
172
|
+
|
|
173
|
+
async def cmd_head(self, args: list[str]) -> None:
|
|
174
|
+
"""Display first lines of a file."""
|
|
175
|
+
await self._print_slice("head", args, tail=False)
|
|
176
|
+
|
|
177
|
+
async def cmd_tail(self, args: list[str]) -> None:
|
|
178
|
+
"""Display last lines of a file."""
|
|
179
|
+
await self._print_slice("tail", args, tail=True)
|
|
180
|
+
|
|
181
|
+
async def _print_slice(self, command: str, args: list[str], tail: bool) -> None:
|
|
182
|
+
"""Helper for head/tail commands."""
|
|
183
|
+
if not args:
|
|
184
|
+
self.stdout.write(f"{command}: missing file operand\n")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
count = 10
|
|
188
|
+
file_index = 0
|
|
189
|
+
|
|
190
|
+
if len(args) >= 2 and args[0] == "-n":
|
|
191
|
+
try:
|
|
192
|
+
count = int(args[1])
|
|
193
|
+
except ValueError:
|
|
194
|
+
self.stdout.write(f"{command}: invalid line count\n")
|
|
195
|
+
return
|
|
196
|
+
file_index = 2
|
|
197
|
+
|
|
198
|
+
if file_index >= len(args):
|
|
199
|
+
self.stdout.write(f"{command}: missing file operand\n")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
file_path = await self._require_file_arg(command, [args[file_index]])
|
|
203
|
+
if file_path is None:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
208
|
+
selected = lines[-count:] if tail else lines[:count]
|
|
209
|
+
self.stdout.write("\n".join(selected) + "\n")
|
|
210
|
+
except UnicodeDecodeError:
|
|
211
|
+
self.stdout.write(f"{command}: cannot read binary file\n")
|
|
212
|
+
|
|
213
|
+
async def _require_file_arg(self, command: str, args: list[str]) -> Optional[Path]:
|
|
214
|
+
"""Validate and return file path from arguments."""
|
|
215
|
+
if not args:
|
|
216
|
+
self.stdout.write(f"{command}: missing file operand\n")
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
virtual_path = self._resolve_virtual_path(args[0])
|
|
220
|
+
real_path = self._to_real_path(virtual_path)
|
|
221
|
+
|
|
222
|
+
if virtual_path == "/invalid" or real_path is None or not real_path.exists():
|
|
223
|
+
self.stdout.write(f"{command}: no such file: {virtual_path}\n")
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
if not real_path.is_file():
|
|
227
|
+
self.stdout.write(f"{command}: is a directory: {virtual_path}\n")
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
return real_path
|
|
231
|
+
|
|
232
|
+
async def cmd_find(self, args: list[str]) -> None:
|
|
233
|
+
"""Find files matching criteria."""
|
|
234
|
+
start_virtual = self._resolve_virtual_path(args[0] if args else self.cwd)
|
|
235
|
+
start_real = self._to_real_path(start_virtual)
|
|
236
|
+
|
|
237
|
+
if start_virtual == "/invalid" or start_real is None or not start_real.exists():
|
|
238
|
+
self.stdout.write(f"find: no such file or directory: {start_virtual}\n")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
name_filter = None
|
|
242
|
+
if len(args) >= 3 and args[1] == "-name":
|
|
243
|
+
name_filter = args[2]
|
|
244
|
+
|
|
245
|
+
paths = (
|
|
246
|
+
[start_real]
|
|
247
|
+
if start_real.is_file()
|
|
248
|
+
else [start_real, *sorted(start_real.rglob("*"))]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for path in paths:
|
|
252
|
+
if name_filter and not self._matches_name(path.name, name_filter):
|
|
253
|
+
continue
|
|
254
|
+
self.stdout.write(f"{self._to_virtual_path(path)}\n")
|
|
255
|
+
|
|
256
|
+
def _matches_name(self, name: str, pattern: str) -> bool:
|
|
257
|
+
"""Check if filename matches glob pattern."""
|
|
258
|
+
regex = "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
|
|
259
|
+
return re.match(regex, name) is not None
|
|
260
|
+
|
|
261
|
+
async def cmd_grep(self, args: list[str]) -> None:
|
|
262
|
+
"""Search file contents."""
|
|
263
|
+
recursive = False
|
|
264
|
+
filtered: list[str] = []
|
|
265
|
+
|
|
266
|
+
for arg in args:
|
|
267
|
+
if arg == "-R":
|
|
268
|
+
recursive = True
|
|
269
|
+
else:
|
|
270
|
+
filtered.append(arg)
|
|
271
|
+
|
|
272
|
+
if len(filtered) < 2:
|
|
273
|
+
self.stdout.write("grep: usage: grep [-R] <pattern> <path>\n")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
pattern = filtered[0]
|
|
277
|
+
start_virtual = self._resolve_virtual_path(filtered[1])
|
|
278
|
+
start_real = self._to_real_path(start_virtual)
|
|
279
|
+
|
|
280
|
+
if start_virtual == "/invalid" or start_real is None or not start_real.exists():
|
|
281
|
+
self.stdout.write(f"grep: no such file or directory: {start_virtual}\n")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if start_real.is_dir() and not recursive:
|
|
285
|
+
self.stdout.write("grep: path is a directory, use -R\n")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
targets = (
|
|
289
|
+
[start_real]
|
|
290
|
+
if start_real.is_file()
|
|
291
|
+
else sorted(p for p in start_real.rglob("*") if p.is_file())
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
found = False
|
|
295
|
+
for target in targets:
|
|
296
|
+
try:
|
|
297
|
+
lines = target.read_text(encoding="utf-8").splitlines()
|
|
298
|
+
except UnicodeDecodeError:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
for index, line in enumerate(lines, start=1):
|
|
302
|
+
if pattern.lower() in line.lower():
|
|
303
|
+
found = True
|
|
304
|
+
self.stdout.write(f"{self._to_virtual_path(target)}:{index}:{line}\n")
|
|
305
|
+
|
|
306
|
+
if not found:
|
|
307
|
+
self.stdout.write("grep: no matches\n")
|