qnap-nas-cli 0.2.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,3 @@
1
+ """QNAP QTS diagnostics CLI."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
qnap_nas_cli/cli.py ADDED
@@ -0,0 +1,327 @@
1
+ import argparse
2
+ import getpass
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ from . import __version__
10
+ from .client import QtsClient
11
+ from .commands import disks_ssh, dump, health, incident_report, network, raid, services, smart_details, storage, sysinfo, tailscale, validate_dump
12
+ from .config import Config, default_config_path, load_config, password_from_environment, save_config
13
+ from .errors import QtsError, TwoFactorRequired
14
+ from .mcp import serve
15
+ from .incident import DEFAULT_MAX_ITEMS, MAX_MAX_ITEMS, response_plan, write_evidence_bundle
16
+ from .remediation import apply_plan, unavailable_plan
17
+
18
+
19
+ def parser() -> argparse.ArgumentParser:
20
+ root = argparse.ArgumentParser(prog="qts", description="QNAP QTS safety-first diagnostics CLI")
21
+ root.add_argument("--version", action="version", version="%(prog)s {}".format(__version__))
22
+ root.add_argument("--config", type=Path, help="設定檔路徑(也可使用 QTS_CONFIG)")
23
+ root.add_argument("--json", action="store_true", help="輸出 JSON")
24
+ root.add_argument("--insecure", action="store_true", help="停用 TLS 憑證驗證(僅限受信任區網暫時使用)")
25
+ subs = root.add_subparsers(dest="command", required=True)
26
+
27
+ setup = subs.add_parser("setup", help="寫入連線設定;不儲存密碼")
28
+ setup.add_argument("--origin", required=True)
29
+ setup.add_argument("--user", required=True)
30
+ setup.add_argument("--ssh-host")
31
+ setup.add_argument("--ssh-user")
32
+ setup.add_argument("--ssh-identity-file")
33
+ setup.add_argument("--timeout", type=int, default=15)
34
+
35
+ subs.add_parser("doctor", help="檢查本機設定與連線前置條件")
36
+ subs.add_parser("whoami", help="登入並顯示目前帳號與 NAS")
37
+ subs.add_parser("status", help="系統型號、韌體、負載、溫度與 uptime")
38
+ health_parser = subs.add_parser("health", help="唯讀磁碟與儲存健康稽核")
39
+ health_parser.add_argument("--volume-index", type=int, action="append", help="額外查詢指定 volume index,可重複指定;預設 1")
40
+ storage_parser = subs.add_parser("storage", aliases=["disks", "volumes"], help="磁碟與儲存空間")
41
+ storage_parser.add_argument("--volume-index", type=int, action="append", help="額外查詢指定 volume index,可重複指定;預設 1")
42
+ subs.add_parser("network", help="網路介面與 DNS")
43
+ subs.add_parser("tailscale", help="透過設定的免密 SSH 讀取 Tailscale")
44
+ subs.add_parser("raid", help="透過免密 SSH 唯讀解析 /proc/mdstat")
45
+ smart_parser = subs.add_parser("smart-details", help="透過免密 SSH 讀取指定 bay 的 SMART attributes")
46
+ smart_parser.add_argument("disk", type=int)
47
+ subs.add_parser("disk-topology", help="透過唯讀 SSH 讀取實體磁碟 bay、角色與型號")
48
+ subs.add_parser("services", help="唯讀檢查 Container Station 與 Tailscale QPKG 狀態")
49
+ dump_parser = subs.add_parser("dump", help="匯出已遮蔽敏感資料的相容性樣本")
50
+ dump_parser.add_argument("directory", nargs="?", type=Path, default=Path("qts-dump"))
51
+ fixture_parser = subs.add_parser("fixture", help="驗證 dump/fixture 是否能安全解析")
52
+ fixture_subs = fixture_parser.add_subparsers(dest="fixture_command", required=True)
53
+ fixture_validate = fixture_subs.add_parser("validate", help="離線驗證已遮蔽的 dump")
54
+ fixture_validate.add_argument("directory", type=Path)
55
+ fixture_validate.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
56
+ subs.add_parser("mcp", help="啟動唯讀 stdio MCP server")
57
+ incident_parser = subs.add_parser("incident", help="勒索事件唯讀盤點、人工 containment/evidence/rescue 工作流")
58
+ incident_subs = incident_parser.add_subparsers(dest="incident_command", required=True)
59
+ incident_scan = incident_subs.add_parser("scan", help="掃描有限、已知勒索指標並彙整健康/RAID evidence")
60
+ incident_scan.add_argument("--max-items", type=int, default=DEFAULT_MAX_ITEMS, help="最多掃描的檔案/資料夾項目數,1..{}".format(MAX_MAX_ITEMS))
61
+ incident_scan.add_argument("--evidence-dir", type=Path, help="可選:建立新的本機 evidence bundle 目錄")
62
+ incident_contain = incident_subs.add_parser("contain", help="從 incident report 產生人工 containment 清單;不修改 NAS")
63
+ incident_contain.add_argument("report", type=Path)
64
+ incident_evidence = incident_subs.add_parser("evidence", help="將既有 incident report 封存並建立 SHA-256 manifest")
65
+ incident_evidence.add_argument("report", type=Path)
66
+ incident_evidence.add_argument("directory", type=Path)
67
+ incident_rescue = incident_subs.add_parser("rescue", help="從 incident report 產生離線備份復原計畫;不覆寫 NAS")
68
+ incident_rescue.add_argument("report", type=Path)
69
+ incident_rescue.add_argument("--candidate-path", help="僅可指定本次 report 已列出的 indicator 範例,匯出一份唯讀本機樣本")
70
+ incident_rescue.add_argument("--directory", type=Path, help="建立新的本機隔離 evidence 目錄;搭配 --candidate-path 使用")
71
+ incident_rescue.add_argument("--max-bytes", type=int, default=100 * 1024 * 1024)
72
+ repair = subs.add_parser("repair", help="一般修復的 plan/apply 閘門;尚無已驗證的 NAS 寫入 adapter")
73
+ repair_subs = repair.add_subparsers(dest="repair_command", required=True)
74
+ repair_plan = repair_subs.add_parser("plan", help="讀取設備身分並輸出明確拒絕執行的 dry-run plan")
75
+ repair_plan.add_argument("--action", required=True, choices=["service-start", "service-stop", "notification-change", "firmware-update", "raid-rebuild", "smart-repair"])
76
+ repair_plan.add_argument("--output", type=Path, default=Path("qts-repair-plan.json"))
77
+ repair_apply = repair_subs.add_parser("apply", help="驗證 plan 與確認碼;未註冊的 mutation adapter 一律拒絕")
78
+ repair_apply.add_argument("plan_file", type=Path)
79
+ repair_apply.add_argument("--confirm", required=True, help="逐字輸入 plan_id;拒絕未驗證操作")
80
+ nested_parsers = (incident_scan, incident_contain, incident_evidence, incident_rescue, repair_plan, repair_apply)
81
+ for command_parser in nested_parsers:
82
+ command_parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
83
+ command_parser.add_argument("--insecure", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
84
+ command_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
85
+ seen = set()
86
+ for command_parser in subs.choices.values():
87
+ if id(command_parser) in seen:
88
+ continue
89
+ seen.add(id(command_parser))
90
+ command_parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
91
+ command_parser.add_argument("--insecure", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
92
+ command_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
93
+ return root
94
+
95
+
96
+ def _config(args: argparse.Namespace) -> Config:
97
+ config = load_config(args.config)
98
+ if args.insecure:
99
+ config.insecure = True
100
+ return config
101
+
102
+
103
+ def _password() -> str:
104
+ password = password_from_environment()
105
+ if password is not None:
106
+ return password
107
+ if not sys.stdin.isatty():
108
+ value = sys.stdin.readline().rstrip("\r\n")
109
+ if value:
110
+ return value
111
+ return getpass.getpass("QTS password: ")
112
+
113
+
114
+ def _security_code() -> Optional[str]:
115
+ value = os.environ.get("QTS_SECURITY_CODE")
116
+ if value is None:
117
+ return None
118
+ value = value.replace(" ", "").strip()
119
+ return value or None
120
+
121
+
122
+ def _prompt_security_code() -> str:
123
+ if not sys.stdin.isatty():
124
+ raise TwoFactorRequired("QTS 帳號需要 2FA;非互動模式請設定當下的 QTS_SECURITY_CODE")
125
+ value = getpass.getpass("QTS security code: ").replace(" ", "").strip()
126
+ if not value:
127
+ raise TwoFactorRequired("QTS 2FA 驗證碼不可為空")
128
+ return value
129
+
130
+
131
+ def _login(client: QtsClient) -> None:
132
+ password = _password()
133
+ try:
134
+ client.login(password, _security_code())
135
+ except TwoFactorRequired:
136
+ client.login(password, _prompt_security_code())
137
+
138
+
139
+ def _read_json(path: Path) -> Dict[str, Any]:
140
+ try:
141
+ value = json.loads(path.read_text(encoding="utf-8"))
142
+ except (OSError, ValueError) as exc:
143
+ raise QtsError("無法讀取 JSON 檔案:{}".format(exc)) from exc
144
+ if not isinstance(value, dict):
145
+ raise QtsError("JSON 根節點必須是 object")
146
+ return value
147
+
148
+
149
+ def _save_new_json(path: Path, value: Dict[str, Any]) -> None:
150
+ try:
151
+ with path.open("x", encoding="utf-8") as stream:
152
+ json.dump(value, stream, ensure_ascii=False, indent=2)
153
+ stream.write("\n")
154
+ except FileExistsError as exc:
155
+ raise QtsError("輸出檔已存在,為避免覆寫 evidence/plan 已拒絕:{}".format(path)) from exc
156
+
157
+
158
+ def _print(value: Any, json_output: bool) -> None:
159
+ if json_output:
160
+ print(json.dumps(value, ensure_ascii=False, indent=2))
161
+ return
162
+ if isinstance(value, dict):
163
+ for key, item in value.items():
164
+ if isinstance(item, (dict, list)):
165
+ print("{}:".format(key))
166
+ rendered = json.dumps(item, ensure_ascii=False, indent=2)
167
+ print("\n".join(" " + line for line in rendered.splitlines()))
168
+ elif item is not None:
169
+ print("{:<20} {}".format(key, item))
170
+ else:
171
+ print(value)
172
+
173
+
174
+ def _doctor(config: Config, config_path: Optional[Path] = None) -> Dict[str, Any]:
175
+ import shutil
176
+ from urllib.parse import urlparse
177
+ parsed = urlparse(config.origin)
178
+ local_http = parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost")
179
+ return {
180
+ "config": str(config_path or default_config_path()),
181
+ "origin": config.origin,
182
+ "username": config.username,
183
+ "tls_verification": not config.insecure,
184
+ "password_available": bool(password_from_environment()),
185
+ "ssh_configured": bool(config.ssh_host and config.ssh_user),
186
+ "ssh_executable": shutil.which("ssh") is not None,
187
+ "safe": (config.origin.startswith("https://") and not config.insecure) or local_http,
188
+ }
189
+
190
+
191
+ def main(argv: Optional[list] = None) -> int:
192
+ args = parser().parse_args(argv)
193
+ try:
194
+ if args.command == "setup":
195
+ target = save_config(Config(args.origin, args.user, args.insecure, args.ssh_host, args.ssh_user, args.timeout, args.ssh_identity_file), args.config)
196
+ _print({"saved": str(target), "password_saved": False}, args.json)
197
+ return 0
198
+ if args.command == "fixture":
199
+ result = validate_dump(args.directory)
200
+ _print(result, args.json)
201
+ return 0 if result["valid"] else 2
202
+ offline_incident = args.command == "incident" and args.incident_command in ("contain", "evidence", "rescue")
203
+ if offline_incident and not (args.incident_command == "rescue" and args.candidate_path):
204
+ report = _read_json(args.report)
205
+ if args.incident_command == "evidence":
206
+ value = write_evidence_bundle(args.directory, report)
207
+ else:
208
+ scan = report.get("ransomware_scan")
209
+ if not isinstance(scan, dict):
210
+ raise QtsError("report 缺少 ransomware_scan")
211
+ if args.incident_command == "contain":
212
+ value = response_plan(scan)
213
+ else:
214
+ value = {
215
+ "status": "manual_rescue_plan_only",
216
+ "actions_applied": [],
217
+ "steps": [
218
+ "在隔離的復原環境使用已驗證的離線/不可變備份;不要把備份直接覆寫到疑似受感染的 NAS。",
219
+ "先完成遏止、可信任重建與漏洞修補;再抽樣驗證檔案雜湊、權限、時間戳及應用可讀性。",
220
+ "分批恢復至新位置並保留原始加密資料與證據;由資料擁有者簽核後才切換服務。",
221
+ ],
222
+ "why": "本工具不會對原 NAS 寫檔、解密、刪除或覆寫;恢復來源與完整性需由管理者確認。",
223
+ }
224
+ _print(value, args.json)
225
+ return 0
226
+ if args.command == "repair" and args.repair_command == "apply":
227
+ plan = _read_json(args.plan_file)
228
+ result = apply_plan(plan, args.confirm, handlers={})
229
+ _print(result, args.json)
230
+ return 2 if result["status"] == "refused" else 0
231
+ config = _config(args)
232
+ if args.command == "doctor":
233
+ _print(_doctor(config, args.config), args.json)
234
+ return 0
235
+ if args.command == "mcp":
236
+ return serve(config)
237
+ if args.command == "incident":
238
+ if args.incident_command == "rescue":
239
+ report = _read_json(args.report)
240
+ scan = report.get("ransomware_scan")
241
+ if not isinstance(scan, dict):
242
+ raise QtsError("report 缺少 ransomware_scan")
243
+ if not args.candidate_path:
244
+ raise QtsError("請提供 --candidate-path,或只產生離線 rescue plan")
245
+ candidates = [example for indicator in scan.get("indicators", []) for example in indicator.get("examples", [])]
246
+ if args.candidate_path not in candidates:
247
+ raise QtsError("只能匯出本 report 列出的 indicator 範例路徑")
248
+ if not args.directory:
249
+ raise QtsError("匯出本機樣本時必須指定新的 --directory")
250
+ if not 1 <= args.max_bytes <= 1024 * 1024 * 1024:
251
+ raise QtsError("max-bytes 必須介於 1 byte 與 1 GiB")
252
+ source_path, _, filename = args.candidate_path.rpartition("/")
253
+ if not source_path or not filename:
254
+ raise QtsError("candidate-path 格式無效")
255
+ report_manifest = write_evidence_bundle(args.directory, report)
256
+ with QtsClient(config) as client:
257
+ _login(client)
258
+ sample = client.download_file(source_path, filename, args.directory / filename, args.max_bytes)
259
+ value = {
260
+ "status": "sample_exported_not_restored",
261
+ "actions_applied": [],
262
+ "source": args.candidate_path,
263
+ "sample": sample,
264
+ "report_manifest": report_manifest,
265
+ "note": "已唯讀匯出到新本機目錄;這不是 NAS 復原,也不會執行或解密樣本。",
266
+ }
267
+ _save_new_json(args.directory / "rescue-manifest.json", value)
268
+ _print(value, args.json)
269
+ return 0
270
+ if not 1 <= args.max_items <= MAX_MAX_ITEMS:
271
+ raise QtsError("max-items 必須介於 1 到 {}".format(MAX_MAX_ITEMS))
272
+ with QtsClient(config) as client:
273
+ _login(client)
274
+ value = incident_report(client, config, args.max_items)
275
+ if args.evidence_dir:
276
+ value["evidence_bundle"] = write_evidence_bundle(args.evidence_dir, value)
277
+ _print(value, args.json)
278
+ return 0
279
+ if args.command == "repair":
280
+ with QtsClient(config) as client:
281
+ _login(client)
282
+ identity = sysinfo(client)
283
+ device = {"origin": config.origin, "model": identity.get("model"), "firmware": identity.get("firmware"), "build": identity.get("build")}
284
+ plan = unavailable_plan(device, args.action)
285
+ _save_new_json(args.output, plan)
286
+ _print({"plan": str(args.output), "plan_id": plan["plan_id"], "status": plan["status"], "refusal_reason": plan["refusal_reason"]}, args.json)
287
+ return 2
288
+ if args.command in ("tailscale", "raid", "disk-topology", "smart-details", "services"):
289
+ ssh_operations = {
290
+ "tailscale": lambda: tailscale(config),
291
+ "raid": lambda: raid(config),
292
+ "disk-topology": lambda: disks_ssh(config),
293
+ "smart-details": lambda: smart_details(config, args.disk),
294
+ "services": lambda: services(config),
295
+ }
296
+ _print(ssh_operations[args.command](), args.json)
297
+ return 0
298
+ operations = {
299
+ "status": sysinfo,
300
+ "health": health,
301
+ "storage": storage,
302
+ "disks": storage,
303
+ "volumes": storage,
304
+ "network": network,
305
+ }
306
+ with QtsClient(config) as client:
307
+ _login(client)
308
+ if args.command == "whoami":
309
+ data = sysinfo(client)
310
+ value = {"username": config.username, "hostname": data.get("hostname"), "model": data.get("model"), "origin": config.origin}
311
+ elif args.command == "dump":
312
+ value = dump(client, args.directory)
313
+ else:
314
+ if args.command in ("storage", "disks", "volumes", "health"):
315
+ indices = getattr(args, "volume_index", None)
316
+ value = health(client, indices) if args.command == "health" else storage(client, indices)
317
+ else:
318
+ value = operations[args.command](client)
319
+ _print(value, args.json)
320
+ return 0
321
+ except (QtsError, OSError) as exc:
322
+ print("error: {}".format(exc), file=sys.stderr)
323
+ return 2
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())
qnap_nas_cli/client.py ADDED
@@ -0,0 +1,201 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import posixpath
5
+ import ssl
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ import xml.etree.ElementTree as ET
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+
13
+ from .config import Config
14
+ from .errors import AuthenticationError, CompatibilityError, QtsError, TwoFactorRequired
15
+
16
+
17
+ AUTH_PATH = "/cgi-bin/authLogin.cgi"
18
+ LOGOUT_PATH = "/cgi-bin/authLogout.cgi"
19
+
20
+
21
+ def _xml_text(root: ET.Element, tag: str) -> Optional[str]:
22
+ node = root.find(".//{}".format(tag))
23
+ return None if node is None else (node.text or "").strip()
24
+
25
+
26
+ class QtsClient:
27
+ def __init__(self, config: Config):
28
+ self.config = config.validate()
29
+ self.sid = None # type: Optional[str]
30
+ self._ssl_context = ssl.create_default_context()
31
+ if config.insecure:
32
+ self._ssl_context.check_hostname = False
33
+ self._ssl_context.verify_mode = ssl.CERT_NONE
34
+
35
+ def _open(self, request: urllib.request.Request) -> bytes:
36
+ try:
37
+ with urllib.request.urlopen(request, timeout=self.config.timeout, context=self._ssl_context) as response:
38
+ return response.read()
39
+ except urllib.error.HTTPError as exc:
40
+ body = exc.read(512).decode("utf-8", "replace").replace("\n", " ")
41
+ raise QtsError("QTS 回傳 HTTP {}:{}".format(exc.code, body or "空白回應")) from exc
42
+ except urllib.error.URLError as exc:
43
+ reason = str(exc.reason)
44
+ if "CERTIFICATE_VERIFY_FAILED" in reason:
45
+ reason += "(請安裝可信憑證;僅在受信任區網暫時使用 --insecure)"
46
+ raise QtsError("無法連線至 NAS:{}".format(reason)) from exc
47
+
48
+ def login(self, password: str, security_code: Optional[str] = None) -> str:
49
+ if not password:
50
+ raise AuthenticationError("密碼不可為空")
51
+ fields = {
52
+ "user": self.config.username,
53
+ "pwd": base64.b64encode(password.encode("utf-8")).decode("ascii"),
54
+ "serviceKey": "1",
55
+ "client_app": "qnap-nas-cli",
56
+ }
57
+ if security_code:
58
+ fields["security_code"] = security_code
59
+ fields["dont_verify_2sv_again"] = "0"
60
+ body = urllib.parse.urlencode(fields).encode("ascii")
61
+ request = urllib.request.Request(
62
+ self.config.origin + AUTH_PATH,
63
+ data=body,
64
+ headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "qnap-nas-cli/0.1"},
65
+ )
66
+ raw = self._open(request)
67
+ try:
68
+ root = ET.fromstring(raw)
69
+ except ET.ParseError as exc:
70
+ raise CompatibilityError("QTS 登入回應不是預期的 XML") from exc
71
+ if _xml_text(root, "authPassed") != "1":
72
+ needs_2fa = _xml_text(root, "need_2_step_verification") == "1" or _xml_text(root, "need_2sv") == "1"
73
+ if needs_2fa and not security_code:
74
+ raise TwoFactorRequired("QTS 帳號需要 2FA")
75
+ if needs_2fa and security_code:
76
+ raise AuthenticationError("QTS 2FA 驗證失敗;請確認驗證碼與 NAS 時間")
77
+ code = _xml_text(root, "errorValue") or "unknown"
78
+ raise AuthenticationError("QTS 登入失敗(errorValue={})".format(code))
79
+ sid = _xml_text(root, "authSid")
80
+ if not sid:
81
+ raise CompatibilityError("QTS 表示登入成功,但沒有回傳 authSid")
82
+ self.sid = sid
83
+ return sid
84
+
85
+ def logout(self) -> None:
86
+ if not self.sid:
87
+ return
88
+ try:
89
+ self.get(LOGOUT_PATH, {}, include_sid=True)
90
+ except QtsError:
91
+ pass
92
+ finally:
93
+ self.sid = None
94
+
95
+ def get(self, path: str, params: Dict[str, str], include_sid: bool = True) -> bytes:
96
+ request = self._get_request(path, params, include_sid)
97
+ return self._open(request)
98
+
99
+ def _get_request(self, path: str, params: Dict[str, str], include_sid: bool = True) -> urllib.request.Request:
100
+ query = dict(params)
101
+ if include_sid:
102
+ if not self.sid:
103
+ raise AuthenticationError("尚未登入 QTS")
104
+ query["sid"] = self.sid
105
+ url = self.config.origin + path + "?" + urllib.parse.urlencode(query)
106
+ return urllib.request.Request(url, headers={"User-Agent": "qnap-nas-cli/0.1"})
107
+
108
+ def download_file(self, source_path: str, filename: str, target: Path,
109
+ max_bytes: int = 100 * 1024 * 1024) -> Dict[str, object]:
110
+ """Stream one selected File Station file to a new local file, bounded by max_bytes."""
111
+ if not self.sid:
112
+ raise AuthenticationError("尚未登入 QTS")
113
+ if not isinstance(source_path, str) or not source_path.startswith("/") or "\x00" in source_path:
114
+ raise QtsError("NAS 檔案路徑格式不安全")
115
+ if any(part in (".", "..") for part in source_path.replace("\\", "/").split("/")):
116
+ raise QtsError("NAS 檔案路徑不得包含 dot segment")
117
+ if not isinstance(filename, str) or filename in ("", ".", "..") or "/" in filename or "\\" in filename or "\x00" in filename:
118
+ raise QtsError("檔名必須是單一安全路徑元件")
119
+ if type(max_bytes) is not int or not 1 <= max_bytes <= 1024 * 1024 * 1024:
120
+ raise QtsError("max_bytes 必須介於 1 byte 與 1 GiB")
121
+
122
+ target = Path(target)
123
+ request = self._get_request("/cgi-bin/filemanager/utilRequest.cgi", {
124
+ "func": "download", "isfolder": "0", "compress": "0",
125
+ "source_path": posixpath.normpath(source_path), "source_file": filename,
126
+ "source_total": "1",
127
+ })
128
+ digest = hashlib.sha256()
129
+ byte_count = 0
130
+ created = False
131
+ try:
132
+ with urllib.request.urlopen(request, timeout=self.config.timeout, context=self._ssl_context) as response:
133
+ content_length = response.headers.get("Content-Length")
134
+ if content_length and int(content_length) > max_bytes:
135
+ raise QtsError("指定檔案超過 rescue 匯出上限 {} bytes".format(max_bytes))
136
+ content_type = (response.headers.get("Content-Type") or "").lower()
137
+ if "text/html" in content_type or "application/json" in content_type:
138
+ raise QtsError("File Station download 回傳登入頁或 JSON 錯誤,不保存為檔案")
139
+ with target.open("xb") as output:
140
+ created = True
141
+ while True:
142
+ chunk = response.read(min(1024 * 1024, max_bytes - byte_count + 1))
143
+ if not chunk:
144
+ break
145
+ byte_count += len(chunk)
146
+ if byte_count > max_bytes:
147
+ raise QtsError("指定檔案超過 rescue 匯出上限 {} bytes".format(max_bytes))
148
+ output.write(chunk)
149
+ digest.update(chunk)
150
+ except FileExistsError as exc:
151
+ raise QtsError("本機 rescue 目標已存在;拒絕覆寫:{}".format(target)) from exc
152
+ except urllib.error.HTTPError as exc:
153
+ if created:
154
+ target.unlink(missing_ok=True)
155
+ raise QtsError("File Station download 回傳 HTTP {}".format(exc.code)) from exc
156
+ except urllib.error.URLError as exc:
157
+ if created:
158
+ target.unlink(missing_ok=True)
159
+ raise QtsError("File Station download 連線失敗:{}".format(exc.reason)) from exc
160
+ except (OSError, ValueError) as exc:
161
+ if created:
162
+ try:
163
+ target.unlink()
164
+ except OSError:
165
+ pass
166
+ raise QtsError("File Station download 寫入失敗:{}".format(exc)) from exc
167
+ except QtsError:
168
+ if created:
169
+ try:
170
+ target.unlink()
171
+ except OSError:
172
+ pass
173
+ raise
174
+ except Exception:
175
+ if created:
176
+ try:
177
+ target.unlink()
178
+ except OSError:
179
+ pass
180
+ raise
181
+ return {"path": str(target), "size_bytes": byte_count, "sha256": digest.hexdigest(), "max_bytes": max_bytes}
182
+
183
+ def get_xml(self, path: str, params: Dict[str, str]) -> ET.Element:
184
+ raw = self.get(path, params)
185
+ try:
186
+ return ET.fromstring(raw)
187
+ except ET.ParseError as exc:
188
+ raise CompatibilityError("{} 回應不是預期的 XML".format(path)) from exc
189
+
190
+ def get_json(self, path: str, params: Dict[str, str]):
191
+ raw = self.get(path, params)
192
+ try:
193
+ return json.loads(raw.decode("utf-8"))
194
+ except (UnicodeDecodeError, ValueError) as exc:
195
+ raise CompatibilityError("{} 回應不是預期的 JSON".format(path)) from exc
196
+
197
+ def __enter__(self) -> "QtsClient":
198
+ return self
199
+
200
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
201
+ self.logout()