bofile 0.1.0__tar.gz

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.
bofile-0.1.0/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ All rights reserved.
2
+
3
+ This package is distributed as proprietary BoFile software. Publication of the
4
+ Python source or wheel does not grant an open-source license or transfer
5
+ ownership of the BoFile name or software. Separate written permission is
6
+ required for redistribution, sublicensing, or commercial incorporation.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ recursive-include tests *.py
bofile-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: bofile
3
+ Version: 0.1.0
4
+ Summary: Standard-library CLI for the local BoFileX HTTP API
5
+ License-Expression: LicenseRef-Proprietary
6
+ Project-URL: Homepage, https://bofile.com/
7
+ Project-URL: Documentation, https://bofile.com/install.html
8
+ Keywords: bofile,bofilex,cli,file-transfer
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Communications :: File Sharing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # bofile — Python CLI for BoFileX
19
+
20
+ Control a **running local BoFileX** using its existing HTTP API. The PyPI
21
+ distribution, Python module and console command are all named `bofile`.
22
+ Python 3.10+; **no runtime dependencies**. This package does not include the
23
+ BoFileX daemon or implement libp2p, start services, or create machine state.
24
+
25
+ ## Install and run
26
+
27
+ After publication:
28
+
29
+ ```sh
30
+ python -m pip install bofile
31
+ bofile --help
32
+ python -m bofile --version
33
+ ```
34
+
35
+ From this directory during development:
36
+
37
+ ```sh
38
+ python -m pip install -e .
39
+ python -m bofile --help
40
+ ```
41
+
42
+ The default API origin is `http://127.0.0.1:34120`, set by
43
+ `client/bofilex_main.go`. BoFileX can choose another free port if that port is
44
+ busy: use the actual HTTP UI origin explicitly. A native-only Wails window may
45
+ not expose this HTTP API. The old dialer API on port 34115 is a different API
46
+ and is not supported here.
47
+
48
+ ```sh
49
+ bofile --base-url http://127.0.0.1:34120 info
50
+ bofile peers --json
51
+ bofile message send PEER_ID "你好,文件已经准备好了"
52
+ bofile files list PEER_ID . --json
53
+ bofile transfer upload PEER_ID ./report.pdf /shared
54
+ bofile transfer download PEER_ID /shared/report.pdf ./downloads --size 12345 --mtime 1700000000
55
+ bofile transfer list --json
56
+ bofile transfer cancel 42
57
+ bofile transfer resume 42
58
+ bofile channels list PEER_ID
59
+ bofile channels select nat --direction up
60
+ bofile channels select auto
61
+ bofile channels mode direct
62
+ bofile channels mode auto
63
+ ```
64
+
65
+ Replace `PEER_ID` with a full peer ID from `peers`. Global flags (`--base-url`,
66
+ `--token-file`, `--timeout`, `--json`) work before or after subcommands.
67
+ Each command supports `--help`. This CLI defaults to a 30-second HTTP timeout.
68
+ There is no automatic retry, since resubmitting a mutation could duplicate work.
69
+
70
+ ## Authentication and connection safety
71
+
72
+ `core/modules/admin/guard.go` allows non-browser scripts that omit Origin,
73
+ Referer and Sec-Fetch-Site. The CLI uses that contract by default and **does not
74
+ read any token, configuration, identity or state file automatically**.
75
+ It never fetches the UI page to harvest a session cookie.
76
+
77
+ When a persisted UI token is needed, explicitly select the existing file:
78
+
79
+ ```sh
80
+ bofile --token-file /path/to/ui-session.token info
81
+ ```
82
+
83
+ The file must contain the 32-character hex UI token. It is sent as the cookie
84
+ `bofile_ui_<API-port>`, not as a Bearer token or query parameter. No token option
85
+ accepts the raw secret on the command line. The file is read only and is never
86
+ created or refreshed. Invalid/missing explicit files fail rather than falling
87
+ back silently. A 401/403 is an error; remote management password login is not
88
+ implemented.
89
+
90
+ BoFileX resolves its own state using `BOFILE_STATE_DIR`, or on Windows usually
91
+ `%ProgramData%\bofilex`, otherwise the platform user config directory with the
92
+ `bofilex` namespace (and native fallback rules). Choose the file used by your
93
+ instance explicitly; this CLI does not reproduce the daemon's state migration
94
+ or executable-directory fallback logic.
95
+
96
+ Only **numeric loopback origins** are accepted (`127.0.0.1`, `[::1]`); DNS names,
97
+ including `localhost`, non-loopback IPs, URL credentials, base paths, queries and
98
+ fragments are rejected before reading a token. Environment HTTP proxies are
99
+ disabled. All redirects are refused, even to another loopback port. This keeps
100
+ cookies and local data from being forwarded to a different endpoint or through
101
+ a proxy. HTTPS uses Python's normal certificate verification. For another
102
+ machine use a trusted SSH tunnel to a local numeric loopback port; remote file
103
+ paths and metadata then belong to that daemon, so upload/download local path
104
+ checks assume the CLI and BoFileX share the same filesystem.
105
+
106
+ ## Transfer and channel semantics
107
+
108
+ - Upload/download **queue a server-side single-file job**. File bytes do not
109
+ pass through Python. A successful command returns a job ID, not a completed
110
+ transfer. Inspect `transfer list` for queued/active/done/error/canceled states.
111
+ Exiting or interrupting the CLI does not cancel submitted work.
112
+ - Upload source paths are resolved to absolute paths and must be regular local
113
+ files. Size comes from the file stat. The destination is a remote directory;
114
+ `--name` changes the destination basename. Directory recursion is not included.
115
+ - Download targets are existing local directories, resolved to absolute paths.
116
+ Use `files list` to obtain the remote `path`, `size` and `mtime` (Unix seconds).
117
+ `--size` is required, including `--size 0` for an empty file. No metadata lookup
118
+ happens implicitly. `--mtime` defaults to zero (unknown). `--overwrite` opts
119
+ into replacing a local destination; otherwise BoFileX chooses a free name.
120
+ Remote upload conflict handling remains the daemon's behavior.
121
+ - Cancel is the daemon's idempotent operation: it may return `status: ok` even
122
+ for an unknown ID. Resume acts on the **same existing job** and its `.part`
123
+ data; the daemon rejects jobs it cannot continue. Queue durability across a
124
+ daemon restart is not promised by this CLI.
125
+ - A message response `sent: true` means **accepted for asynchronous delivery**.
126
+ The daemon may subsequently queue/retry delivery or record a refusal.
127
+ - `channels list PEER_ID` can focus the engine on that peer when no UI socket
128
+ holds focus. An open UI socket may retain focus instead; check the response's
129
+ `peer`. `channels select` and `channels mode` change the **shared currently
130
+ focused engine**, not a per-command or per-peer isolated session. Selecting
131
+ `auto` sends an empty pin; selecting all directions clears directional pins.
132
+ A pin may fall back to another ready path. `direct`/`relay` modes clear pins.
133
+ - `files list` can update the daemon's remembered remote pane directory.
134
+ `peers` may refresh discovery. These are existing API side effects.
135
+
136
+ ## Output, errors and API mapping
137
+
138
+ `--json` prints the API response as one JSON value on stdout. Human output is
139
+ pretty JSON except for explicit message submission/transfer queue acknowledgments.
140
+ Operational errors use stderr (a JSON `{"error": "..."}` object with `--json`),
141
+ with no traceback or HTTP response body dump. API `error` fields fail even when
142
+ HTTP status is 200. HTTP responses are limited to 8 MiB.
143
+
144
+ Exit codes: `0` success, `1` operational/API error, `2` argparse usage error,
145
+ `130` interrupted. Argparse usage errors remain standard text, including with
146
+ `--json`. A timeout or lost connection does not prove that a mutation failed;
147
+ check daemon state before submitting it again.
148
+
149
+ | Command | Local route | Method / query |
150
+ | --- | --- | --- |
151
+ | `info` | `/api/info` | GET |
152
+ | `peers` | `/api/peers` | GET |
153
+ | `message send` | `/api/peers/chat/send` | POST; peer, text |
154
+ | `files list` | `/api/remote/listdir` | GET; peer, path |
155
+ | `transfer list` | `/api/transfers` | GET |
156
+ | `transfer upload/download` | `/api/transfer/add` | POST; peer, peername, dir=up/down, src, target, name, size; download: mtime, overwrite |
157
+ | `transfer cancel/resume` | `/api/transfer/cancel`, `/api/transfer/resume` | POST; id |
158
+ | `channels list` | `/api/peers/channels` | GET; peer |
159
+ | `channels select` | `/api/peers/channels/select` | POST; name, dir=empty/up/down |
160
+ | `channels mode` | `/api/peers/channels/mode` | POST; mode=auto/direct/relay |
161
+
162
+ Mutations use POST with **URL query parameters and an empty body**, matching
163
+ the Go handlers' `r.URL.Query()` implementation. Do not log full request URLs
164
+ if message content or filesystem paths are sensitive. Peer IDs and paths are
165
+ encoded query values and can never change the HTTP destination.
166
+
167
+ ## Test, build and release handoff
168
+
169
+ From this directory (PowerShell):
170
+
171
+ ```powershell
172
+ $env:PYTHONPATH = "$PWD/src"
173
+ python -m unittest discover -s tests -v
174
+ python -m build
175
+ ```
176
+
177
+ Tests use only ephemeral loopback mock HTTP servers and temporary files. No
178
+ test contacts a running BoFileX, reads real tokens, starts/stops services, or
179
+ publishes anything. Coverage includes route/query contracts, file metadata,
180
+ Unicode escaping, cookie auth, redirects, proxy isolation, URL restrictions,
181
+ JSON/API errors, and validation before any mutation.
182
+
183
+ `python -m build` creates `dist/bofile-0.1.0-py3-none-any.whl` and
184
+ `dist/bofile-0.1.0.tar.gz`. Setuptools is a build dependency only. The sdist
185
+ includes the tests. Review package metadata, ownership/licensing and release
186
+ version before publication; PyPI name availability does not reserve a name.
187
+ The release owner handles PyPI credentials, final validation and publishing.
188
+ This implementation deliberately does not upload to PyPI or TestPyPI.
bofile-0.1.0/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # bofile — Python CLI for BoFileX
2
+
3
+ Control a **running local BoFileX** using its existing HTTP API. The PyPI
4
+ distribution, Python module and console command are all named `bofile`.
5
+ Python 3.10+; **no runtime dependencies**. This package does not include the
6
+ BoFileX daemon or implement libp2p, start services, or create machine state.
7
+
8
+ ## Install and run
9
+
10
+ After publication:
11
+
12
+ ```sh
13
+ python -m pip install bofile
14
+ bofile --help
15
+ python -m bofile --version
16
+ ```
17
+
18
+ From this directory during development:
19
+
20
+ ```sh
21
+ python -m pip install -e .
22
+ python -m bofile --help
23
+ ```
24
+
25
+ The default API origin is `http://127.0.0.1:34120`, set by
26
+ `client/bofilex_main.go`. BoFileX can choose another free port if that port is
27
+ busy: use the actual HTTP UI origin explicitly. A native-only Wails window may
28
+ not expose this HTTP API. The old dialer API on port 34115 is a different API
29
+ and is not supported here.
30
+
31
+ ```sh
32
+ bofile --base-url http://127.0.0.1:34120 info
33
+ bofile peers --json
34
+ bofile message send PEER_ID "你好,文件已经准备好了"
35
+ bofile files list PEER_ID . --json
36
+ bofile transfer upload PEER_ID ./report.pdf /shared
37
+ bofile transfer download PEER_ID /shared/report.pdf ./downloads --size 12345 --mtime 1700000000
38
+ bofile transfer list --json
39
+ bofile transfer cancel 42
40
+ bofile transfer resume 42
41
+ bofile channels list PEER_ID
42
+ bofile channels select nat --direction up
43
+ bofile channels select auto
44
+ bofile channels mode direct
45
+ bofile channels mode auto
46
+ ```
47
+
48
+ Replace `PEER_ID` with a full peer ID from `peers`. Global flags (`--base-url`,
49
+ `--token-file`, `--timeout`, `--json`) work before or after subcommands.
50
+ Each command supports `--help`. This CLI defaults to a 30-second HTTP timeout.
51
+ There is no automatic retry, since resubmitting a mutation could duplicate work.
52
+
53
+ ## Authentication and connection safety
54
+
55
+ `core/modules/admin/guard.go` allows non-browser scripts that omit Origin,
56
+ Referer and Sec-Fetch-Site. The CLI uses that contract by default and **does not
57
+ read any token, configuration, identity or state file automatically**.
58
+ It never fetches the UI page to harvest a session cookie.
59
+
60
+ When a persisted UI token is needed, explicitly select the existing file:
61
+
62
+ ```sh
63
+ bofile --token-file /path/to/ui-session.token info
64
+ ```
65
+
66
+ The file must contain the 32-character hex UI token. It is sent as the cookie
67
+ `bofile_ui_<API-port>`, not as a Bearer token or query parameter. No token option
68
+ accepts the raw secret on the command line. The file is read only and is never
69
+ created or refreshed. Invalid/missing explicit files fail rather than falling
70
+ back silently. A 401/403 is an error; remote management password login is not
71
+ implemented.
72
+
73
+ BoFileX resolves its own state using `BOFILE_STATE_DIR`, or on Windows usually
74
+ `%ProgramData%\bofilex`, otherwise the platform user config directory with the
75
+ `bofilex` namespace (and native fallback rules). Choose the file used by your
76
+ instance explicitly; this CLI does not reproduce the daemon's state migration
77
+ or executable-directory fallback logic.
78
+
79
+ Only **numeric loopback origins** are accepted (`127.0.0.1`, `[::1]`); DNS names,
80
+ including `localhost`, non-loopback IPs, URL credentials, base paths, queries and
81
+ fragments are rejected before reading a token. Environment HTTP proxies are
82
+ disabled. All redirects are refused, even to another loopback port. This keeps
83
+ cookies and local data from being forwarded to a different endpoint or through
84
+ a proxy. HTTPS uses Python's normal certificate verification. For another
85
+ machine use a trusted SSH tunnel to a local numeric loopback port; remote file
86
+ paths and metadata then belong to that daemon, so upload/download local path
87
+ checks assume the CLI and BoFileX share the same filesystem.
88
+
89
+ ## Transfer and channel semantics
90
+
91
+ - Upload/download **queue a server-side single-file job**. File bytes do not
92
+ pass through Python. A successful command returns a job ID, not a completed
93
+ transfer. Inspect `transfer list` for queued/active/done/error/canceled states.
94
+ Exiting or interrupting the CLI does not cancel submitted work.
95
+ - Upload source paths are resolved to absolute paths and must be regular local
96
+ files. Size comes from the file stat. The destination is a remote directory;
97
+ `--name` changes the destination basename. Directory recursion is not included.
98
+ - Download targets are existing local directories, resolved to absolute paths.
99
+ Use `files list` to obtain the remote `path`, `size` and `mtime` (Unix seconds).
100
+ `--size` is required, including `--size 0` for an empty file. No metadata lookup
101
+ happens implicitly. `--mtime` defaults to zero (unknown). `--overwrite` opts
102
+ into replacing a local destination; otherwise BoFileX chooses a free name.
103
+ Remote upload conflict handling remains the daemon's behavior.
104
+ - Cancel is the daemon's idempotent operation: it may return `status: ok` even
105
+ for an unknown ID. Resume acts on the **same existing job** and its `.part`
106
+ data; the daemon rejects jobs it cannot continue. Queue durability across a
107
+ daemon restart is not promised by this CLI.
108
+ - A message response `sent: true` means **accepted for asynchronous delivery**.
109
+ The daemon may subsequently queue/retry delivery or record a refusal.
110
+ - `channels list PEER_ID` can focus the engine on that peer when no UI socket
111
+ holds focus. An open UI socket may retain focus instead; check the response's
112
+ `peer`. `channels select` and `channels mode` change the **shared currently
113
+ focused engine**, not a per-command or per-peer isolated session. Selecting
114
+ `auto` sends an empty pin; selecting all directions clears directional pins.
115
+ A pin may fall back to another ready path. `direct`/`relay` modes clear pins.
116
+ - `files list` can update the daemon's remembered remote pane directory.
117
+ `peers` may refresh discovery. These are existing API side effects.
118
+
119
+ ## Output, errors and API mapping
120
+
121
+ `--json` prints the API response as one JSON value on stdout. Human output is
122
+ pretty JSON except for explicit message submission/transfer queue acknowledgments.
123
+ Operational errors use stderr (a JSON `{"error": "..."}` object with `--json`),
124
+ with no traceback or HTTP response body dump. API `error` fields fail even when
125
+ HTTP status is 200. HTTP responses are limited to 8 MiB.
126
+
127
+ Exit codes: `0` success, `1` operational/API error, `2` argparse usage error,
128
+ `130` interrupted. Argparse usage errors remain standard text, including with
129
+ `--json`. A timeout or lost connection does not prove that a mutation failed;
130
+ check daemon state before submitting it again.
131
+
132
+ | Command | Local route | Method / query |
133
+ | --- | --- | --- |
134
+ | `info` | `/api/info` | GET |
135
+ | `peers` | `/api/peers` | GET |
136
+ | `message send` | `/api/peers/chat/send` | POST; peer, text |
137
+ | `files list` | `/api/remote/listdir` | GET; peer, path |
138
+ | `transfer list` | `/api/transfers` | GET |
139
+ | `transfer upload/download` | `/api/transfer/add` | POST; peer, peername, dir=up/down, src, target, name, size; download: mtime, overwrite |
140
+ | `transfer cancel/resume` | `/api/transfer/cancel`, `/api/transfer/resume` | POST; id |
141
+ | `channels list` | `/api/peers/channels` | GET; peer |
142
+ | `channels select` | `/api/peers/channels/select` | POST; name, dir=empty/up/down |
143
+ | `channels mode` | `/api/peers/channels/mode` | POST; mode=auto/direct/relay |
144
+
145
+ Mutations use POST with **URL query parameters and an empty body**, matching
146
+ the Go handlers' `r.URL.Query()` implementation. Do not log full request URLs
147
+ if message content or filesystem paths are sensitive. Peer IDs and paths are
148
+ encoded query values and can never change the HTTP destination.
149
+
150
+ ## Test, build and release handoff
151
+
152
+ From this directory (PowerShell):
153
+
154
+ ```powershell
155
+ $env:PYTHONPATH = "$PWD/src"
156
+ python -m unittest discover -s tests -v
157
+ python -m build
158
+ ```
159
+
160
+ Tests use only ephemeral loopback mock HTTP servers and temporary files. No
161
+ test contacts a running BoFileX, reads real tokens, starts/stops services, or
162
+ publishes anything. Coverage includes route/query contracts, file metadata,
163
+ Unicode escaping, cookie auth, redirects, proxy isolation, URL restrictions,
164
+ JSON/API errors, and validation before any mutation.
165
+
166
+ `python -m build` creates `dist/bofile-0.1.0-py3-none-any.whl` and
167
+ `dist/bofile-0.1.0.tar.gz`. Setuptools is a build dependency only. The sdist
168
+ includes the tests. Review package metadata, ownership/licensing and release
169
+ version before publication; PyPI name availability does not reserve a name.
170
+ The release owner handles PyPI credentials, final validation and publishing.
171
+ This implementation deliberately does not upload to PyPI or TestPyPI.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bofile"
7
+ version = "0.1.0"
8
+ description = "Standard-library CLI for the local BoFileX HTTP API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ keywords = ["bofile", "bofilex", "cli", "file-transfer"]
12
+ license = "LicenseRef-Proprietary"
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Topic :: Communications :: File Sharing",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://bofile.com/"
22
+ Documentation = "https://bofile.com/install.html"
23
+
24
+ [project.scripts]
25
+ bofile = "bofile.cli:main"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
bofile-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """A local HTTP client for BoFileX, with no runtime dependencies."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,196 @@
1
+ """Command parsing and the BoFileX peer API mapping."""
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .client import BofileError, Client, DEFAULT_BASE_URL
12
+
13
+
14
+ def nonempty(value):
15
+ if not value.strip():
16
+ raise argparse.ArgumentTypeError("must not be empty")
17
+ return value
18
+
19
+
20
+ def nonnegative(value):
21
+ try:
22
+ number = int(value)
23
+ if not 0 <= number <= 2**63 - 1:
24
+ raise ValueError
25
+ return number
26
+ except ValueError:
27
+ raise argparse.ArgumentTypeError("must be an integer from 0 to 9223372036854775807") from None
28
+
29
+
30
+ def positive(value):
31
+ number = nonnegative(value)
32
+ if not number:
33
+ raise argparse.ArgumentTypeError("must be greater than zero")
34
+ return number
35
+
36
+
37
+ def timeout_value(value):
38
+ try:
39
+ number = float(value)
40
+ if not math.isfinite(number) or number <= 0:
41
+ raise ValueError
42
+ return number
43
+ except ValueError:
44
+ raise argparse.ArgumentTypeError("must be a finite positive number") from None
45
+
46
+
47
+ def global_options(parser, *, root=False):
48
+ # Suppressed child defaults allow options both before and after subcommands.
49
+ def default(value):
50
+ return value if root else argparse.SUPPRESS
51
+ parser.add_argument("--base-url", default=default(DEFAULT_BASE_URL),
52
+ help="numeric loopback HTTP origin (default: http://127.0.0.1:34120)")
53
+ parser.add_argument("--token-file", default=default(None),
54
+ help="explicit ui-session.token file; never auto-discovered")
55
+ parser.add_argument("--timeout", type=timeout_value, default=default(30),
56
+ help="HTTP timeout in seconds (default: 30)")
57
+ parser.add_argument("--json", action="store_true", default=default(False),
58
+ help="machine-readable JSON output (and operational errors)")
59
+
60
+
61
+ def parser():
62
+ root = argparse.ArgumentParser(
63
+ prog="bofile", description="Control a running local BoFileX through its HTTP API.",
64
+ epilog="No daemon is started. Jobs run in BoFileX after this command exits.")
65
+ global_options(root, root=True)
66
+ root.add_argument("--version", action="version", version="bofile " + __version__)
67
+ commands = root.add_subparsers(dest="command", required=True)
68
+
69
+ def add(subs, name, help_text):
70
+ child = subs.add_parser(name, help=help_text, description=help_text)
71
+ global_options(child)
72
+ return child
73
+
74
+ add(commands, "info", "Show local instance information.")
75
+ add(commands, "peers", "List known circle peers (may refresh discovery).")
76
+ message = add(commands, "message", "Send text to a peer.")
77
+ send = add(message.add_subparsers(dest="action", required=True), "send",
78
+ "Submit a message; accepted does not mean delivered.")
79
+ send.add_argument("peer", type=nonempty, help="full peer ID")
80
+ send.add_argument("text", type=nonempty, help="message text; quote spaces")
81
+
82
+ transfer = add(commands, "transfer", "Manage server-side single-file transfer jobs.")
83
+ transfers = transfer.add_subparsers(dest="action", required=True)
84
+ add(transfers, "list", "List the entire local transfer queue.")
85
+ for action in ("upload", "download"):
86
+ p = add(transfers, action, "Queue a single-file " + action + ". Paths belong to the local BoFileX host.")
87
+ p.add_argument("peer", type=nonempty, help="full destination/source peer ID")
88
+ p.add_argument("source", type=nonempty, help="local file" if action == "upload" else "remote file path")
89
+ p.add_argument("target", type=nonempty, help="remote directory" if action == "upload" else "existing local directory")
90
+ p.add_argument("--name", type=nonempty, help="destination basename (default: source basename)")
91
+ p.add_argument("--peer-name", default="", help="optional display label recorded on the job")
92
+ if action == "download":
93
+ p.add_argument("--size", type=nonnegative, required=True, help="remote file size in bytes; use files list")
94
+ p.add_argument("--mtime", type=nonnegative, default=0, help="remote Unix mtime in seconds")
95
+ p.add_argument("--overwrite", action="store_true", help="replace an existing local destination; default chooses a free name")
96
+ for action in ("cancel", "resume"):
97
+ p = add(transfers, action, action.capitalize() + " an existing transfer ID.")
98
+ p.add_argument("id", type=positive)
99
+
100
+ channels = add(commands, "channels", "Inspect or change the shared channel engine.")
101
+ channel_subs = channels.add_subparsers(dest="action", required=True)
102
+ p = add(channel_subs, "list", "List channels for a peer; may focus the engine when no UI socket is open.")
103
+ p.add_argument("peer", type=nonempty)
104
+ p = add(channel_subs, "select", "Pin a channel on the currently focused engine (shared with the UI).")
105
+ p.add_argument("name", type=nonempty, help="channel name, or auto to clear the pin")
106
+ p.add_argument("--direction", choices=("all", "up", "down"), default="all")
107
+ p = add(channel_subs, "mode", "Set shared engine routing mode; direct/relay clear channel pins.")
108
+ p.add_argument("mode", choices=("auto", "direct", "relay"))
109
+
110
+ files = add(commands, "files", "Inspect remote file metadata before downloading.")
111
+ p = add(files.add_subparsers(dest="action", required=True), "list",
112
+ "List a remote directory; BoFileX remembers the pane directory.")
113
+ p.add_argument("peer", type=nonempty)
114
+ p.add_argument("path", nargs="?", default=".", help="remote directory (default: shared root .)")
115
+ return root
116
+
117
+
118
+ def basename(value):
119
+ return value.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
120
+
121
+
122
+ def safe_name(value):
123
+ if value in ("", ".", "..") or any(c in value for c in ("/", "\\", ":", "\x00")):
124
+ raise BofileError("destination name must be a basename without path separators or drive prefix")
125
+ return value
126
+
127
+
128
+ def execute(args, client):
129
+ if args.command in ("info", "peers"):
130
+ return client.request("/api/" + args.command)
131
+ if args.command == "message":
132
+ return client.request("/api/peers/chat/send", {"peer": args.peer, "text": args.text}, mutate=True)
133
+ if args.command == "files":
134
+ return client.request("/api/remote/listdir", {"peer": args.peer, "path": args.path})
135
+ if args.command == "channels":
136
+ if args.action == "list":
137
+ return client.request("/api/peers/channels", {"peer": args.peer})
138
+ if args.action == "mode":
139
+ return client.request("/api/peers/channels/mode", {"mode": args.mode}, mutate=True)
140
+ return client.request("/api/peers/channels/select", {
141
+ "name": "" if args.name == "auto" else args.name,
142
+ "dir": "" if args.direction == "all" else args.direction,
143
+ }, mutate=True)
144
+ if args.action == "list":
145
+ return client.request("/api/transfers")
146
+ if args.action in ("cancel", "resume"):
147
+ return client.request("/api/transfer/" + args.action, {"id": args.id}, mutate=True)
148
+ name = safe_name(args.name or basename(args.source))
149
+ if args.action == "upload":
150
+ source = Path(args.source).expanduser().resolve()
151
+ if not source.is_file():
152
+ raise BofileError("upload source must be an existing regular file")
153
+ size = source.stat().st_size
154
+ if size > 2**63 - 1:
155
+ raise BofileError("upload source exceeds the API size limit")
156
+ params = {"dir": "up", "src": str(source), "target": args.target, "size": size}
157
+ else:
158
+ target = Path(args.target).expanduser().resolve()
159
+ if not target.is_dir():
160
+ raise BofileError("download target must be an existing local directory")
161
+ params = {"dir": "down", "src": args.source, "target": str(target),
162
+ "size": args.size, "mtime": args.mtime, "overwrite": "1" if args.overwrite else "0"}
163
+ params.update(peer=args.peer, peername=args.peer_name, name=name)
164
+ return client.request("/api/transfer/add", params, mutate=True)
165
+
166
+
167
+ def human_output(result, args):
168
+ if isinstance(result, dict):
169
+ if args.command == "message" and result.get("sent"):
170
+ return "Message accepted (delivery is asynchronous). ID: " + str(result.get("id", "unknown"))
171
+ if args.command == "transfer" and args.action in ("upload", "download") and "id" in result:
172
+ return f"Transfer queued. ID: {result['id']} (use bofile transfer list to check progress)"
173
+ return json.dumps(result, ensure_ascii=False, indent=2)
174
+
175
+
176
+ def main(argv=None):
177
+ args = parser().parse_args(argv)
178
+ try:
179
+ client = Client(args.base_url, timeout=args.timeout, token_file=args.token_file)
180
+ result = execute(args, client)
181
+ print(json.dumps(result, ensure_ascii=False) if args.json else human_output(result, args))
182
+ return 0
183
+ except (BofileError, OSError) as exc:
184
+ detail = str(exc) if isinstance(exc, BofileError) else "cannot access the specified local file or directory"
185
+ if args.json:
186
+ print(json.dumps({"error": detail}, ensure_ascii=False), file=sys.stderr)
187
+ else:
188
+ print("bofile: " + detail, file=sys.stderr)
189
+ return 1
190
+ except KeyboardInterrupt:
191
+ print("bofile: interrupted; submitted jobs may still be running", file=sys.stderr)
192
+ return 130
193
+
194
+
195
+ if __name__ == "__main__":
196
+ sys.exit(main())
@@ -0,0 +1,133 @@
1
+ """Bounded, loopback-only transport. Never discovers or writes machine state."""
2
+
3
+ import ipaddress
4
+ import json
5
+ import math
6
+ import re
7
+ import socket
8
+ from http.client import HTTPException
9
+ from pathlib import Path
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import urlencode, urlsplit
12
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
13
+
14
+ from . import __version__
15
+
16
+ DEFAULT_BASE_URL = "http://127.0.0.1:34120"
17
+ MAX_RESPONSE = 8 * 1024 * 1024
18
+
19
+
20
+ class BofileError(Exception):
21
+ """An actionable error safe to present without a traceback."""
22
+
23
+
24
+ class NoRedirect(HTTPRedirectHandler):
25
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
26
+ return None
27
+
28
+
29
+ def validate_base_url(value):
30
+ """Accept only literal loopback origins, avoiding DNS rebinding entirely."""
31
+ try:
32
+ if any(ord(c) <= 32 or ord(c) == 127 for c in value):
33
+ raise ValueError
34
+ parsed = urlsplit(value)
35
+ address = ipaddress.ip_address(parsed.hostname or "")
36
+ if (
37
+ parsed.scheme not in ("http", "https")
38
+ or not address.is_loopback
39
+ or getattr(address, "ipv4_mapped", None) is not None
40
+ or "%" in (parsed.hostname or "")
41
+ or parsed.username is not None
42
+ or parsed.password is not None
43
+ or parsed.path not in ("", "/")
44
+ or parsed.query
45
+ or parsed.fragment
46
+ or "?" in value
47
+ or "#" in value
48
+ ):
49
+ raise ValueError
50
+ port = parsed.port
51
+ if port is not None and not 1 <= port <= 65535:
52
+ raise ValueError
53
+ if parsed.netloc.endswith(":"):
54
+ raise ValueError
55
+ except ValueError:
56
+ raise BofileError(
57
+ "--base-url must be an http(s) numeric loopback origin, e.g. "
58
+ "http://127.0.0.1:34120 or http://[::1]:34120 (no path or credentials)"
59
+ ) from None
60
+ return value.rstrip("/"), port or (443 if parsed.scheme == "https" else 80)
61
+
62
+
63
+ class Client:
64
+ def __init__(self, base_url=DEFAULT_BASE_URL, *, timeout=30, token_file=None):
65
+ # Validate the destination BEFORE reading any local credential.
66
+ self.base_url, self.port = validate_base_url(base_url)
67
+ if not math.isfinite(timeout) or timeout <= 0:
68
+ raise BofileError("--timeout must be a finite positive number")
69
+ self.timeout = timeout
70
+ self.token = None
71
+ if token_file:
72
+ try:
73
+ with Path(token_file).open("r", encoding="utf-8") as stream:
74
+ token = stream.read(4097).strip()
75
+ except (OSError, UnicodeError):
76
+ raise BofileError("cannot read --token-file as UTF-8 text") from None
77
+ # Tokens minted by NewUISessionToken are 16 random bytes in hex.
78
+ if not re.fullmatch(r"[0-9a-fA-F]{32}", token):
79
+ raise BofileError("--token-file must contain a 32-character hex UI session token")
80
+ self.token = token
81
+ # Environment proxies must never receive the session cookie or local data.
82
+ self.opener = build_opener(ProxyHandler({}), NoRedirect())
83
+
84
+ def _safe_detail(self, value):
85
+ text = str(value)
86
+ if self.token:
87
+ text = text.replace(self.token, "[redacted]")
88
+ return "".join(c if c.isprintable() else " " for c in text)[:500]
89
+
90
+ def request(self, path, params=None, *, mutate=False):
91
+ # Fixed API routes only; a peer/path can never replace the destination.
92
+ if not re.fullmatch(r"/api/[a-z/]+", path):
93
+ raise BofileError("invalid API route")
94
+ url = self.base_url + path
95
+ if params:
96
+ url += "?" + urlencode(params)
97
+ headers = {"Accept": "application/json", "User-Agent": "bofile/" + __version__}
98
+ if self.token:
99
+ headers["Cookie"] = f"bofile_ui_{self.port}={self.token}"
100
+ # The Go handlers read URL.Query(), even for POST. Do not use a JSON body.
101
+ req = Request(url, data=b"" if mutate else None, headers=headers,
102
+ method="POST" if mutate else "GET")
103
+ try:
104
+ with self.opener.open(req, timeout=self.timeout) as response:
105
+ payload = response.read(MAX_RESPONSE + 1)
106
+ except HTTPError as exc:
107
+ code = exc.code
108
+ exc.close()
109
+ if 300 <= code < 400:
110
+ raise BofileError("HTTP redirect refused; use the local API's exact origin") from None
111
+ if code in (401, 403):
112
+ raise BofileError(
113
+ f"HTTP {code}: access refused; check the local port and --token-file. "
114
+ "Remote management/password login is not supported."
115
+ ) from None
116
+ raise BofileError(f"HTTP {code} from local API") from None
117
+ except (TimeoutError, socket.timeout):
118
+ raise BofileError("local API timed out; a submitted operation may still be running; inspect state before retrying") from None
119
+ except HTTPException:
120
+ raise BofileError("local API connection ended unexpectedly; inspect state before retrying a submitted operation") from None
121
+ except (URLError, OSError, ValueError):
122
+ raise BofileError("cannot reach local BoFileX API; start BoFileX with its HTTP UI and check --base-url") from None
123
+ if len(payload) > MAX_RESPONSE:
124
+ raise BofileError("local API response exceeds 8 MiB")
125
+ try:
126
+ result = json.loads(payload)
127
+ except (ValueError, UnicodeError, RecursionError):
128
+ raise BofileError("local API returned invalid JSON; check the port and HTTP UI availability") from None
129
+ if not isinstance(result, (dict, list)):
130
+ raise BofileError("local API returned an unexpected JSON shape")
131
+ if isinstance(result, dict) and result.get("error"):
132
+ raise BofileError("API: " + self._safe_detail(result["error"]))
133
+ return result
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: bofile
3
+ Version: 0.1.0
4
+ Summary: Standard-library CLI for the local BoFileX HTTP API
5
+ License-Expression: LicenseRef-Proprietary
6
+ Project-URL: Homepage, https://bofile.com/
7
+ Project-URL: Documentation, https://bofile.com/install.html
8
+ Keywords: bofile,bofilex,cli,file-transfer
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Communications :: File Sharing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # bofile — Python CLI for BoFileX
19
+
20
+ Control a **running local BoFileX** using its existing HTTP API. The PyPI
21
+ distribution, Python module and console command are all named `bofile`.
22
+ Python 3.10+; **no runtime dependencies**. This package does not include the
23
+ BoFileX daemon or implement libp2p, start services, or create machine state.
24
+
25
+ ## Install and run
26
+
27
+ After publication:
28
+
29
+ ```sh
30
+ python -m pip install bofile
31
+ bofile --help
32
+ python -m bofile --version
33
+ ```
34
+
35
+ From this directory during development:
36
+
37
+ ```sh
38
+ python -m pip install -e .
39
+ python -m bofile --help
40
+ ```
41
+
42
+ The default API origin is `http://127.0.0.1:34120`, set by
43
+ `client/bofilex_main.go`. BoFileX can choose another free port if that port is
44
+ busy: use the actual HTTP UI origin explicitly. A native-only Wails window may
45
+ not expose this HTTP API. The old dialer API on port 34115 is a different API
46
+ and is not supported here.
47
+
48
+ ```sh
49
+ bofile --base-url http://127.0.0.1:34120 info
50
+ bofile peers --json
51
+ bofile message send PEER_ID "你好,文件已经准备好了"
52
+ bofile files list PEER_ID . --json
53
+ bofile transfer upload PEER_ID ./report.pdf /shared
54
+ bofile transfer download PEER_ID /shared/report.pdf ./downloads --size 12345 --mtime 1700000000
55
+ bofile transfer list --json
56
+ bofile transfer cancel 42
57
+ bofile transfer resume 42
58
+ bofile channels list PEER_ID
59
+ bofile channels select nat --direction up
60
+ bofile channels select auto
61
+ bofile channels mode direct
62
+ bofile channels mode auto
63
+ ```
64
+
65
+ Replace `PEER_ID` with a full peer ID from `peers`. Global flags (`--base-url`,
66
+ `--token-file`, `--timeout`, `--json`) work before or after subcommands.
67
+ Each command supports `--help`. This CLI defaults to a 30-second HTTP timeout.
68
+ There is no automatic retry, since resubmitting a mutation could duplicate work.
69
+
70
+ ## Authentication and connection safety
71
+
72
+ `core/modules/admin/guard.go` allows non-browser scripts that omit Origin,
73
+ Referer and Sec-Fetch-Site. The CLI uses that contract by default and **does not
74
+ read any token, configuration, identity or state file automatically**.
75
+ It never fetches the UI page to harvest a session cookie.
76
+
77
+ When a persisted UI token is needed, explicitly select the existing file:
78
+
79
+ ```sh
80
+ bofile --token-file /path/to/ui-session.token info
81
+ ```
82
+
83
+ The file must contain the 32-character hex UI token. It is sent as the cookie
84
+ `bofile_ui_<API-port>`, not as a Bearer token or query parameter. No token option
85
+ accepts the raw secret on the command line. The file is read only and is never
86
+ created or refreshed. Invalid/missing explicit files fail rather than falling
87
+ back silently. A 401/403 is an error; remote management password login is not
88
+ implemented.
89
+
90
+ BoFileX resolves its own state using `BOFILE_STATE_DIR`, or on Windows usually
91
+ `%ProgramData%\bofilex`, otherwise the platform user config directory with the
92
+ `bofilex` namespace (and native fallback rules). Choose the file used by your
93
+ instance explicitly; this CLI does not reproduce the daemon's state migration
94
+ or executable-directory fallback logic.
95
+
96
+ Only **numeric loopback origins** are accepted (`127.0.0.1`, `[::1]`); DNS names,
97
+ including `localhost`, non-loopback IPs, URL credentials, base paths, queries and
98
+ fragments are rejected before reading a token. Environment HTTP proxies are
99
+ disabled. All redirects are refused, even to another loopback port. This keeps
100
+ cookies and local data from being forwarded to a different endpoint or through
101
+ a proxy. HTTPS uses Python's normal certificate verification. For another
102
+ machine use a trusted SSH tunnel to a local numeric loopback port; remote file
103
+ paths and metadata then belong to that daemon, so upload/download local path
104
+ checks assume the CLI and BoFileX share the same filesystem.
105
+
106
+ ## Transfer and channel semantics
107
+
108
+ - Upload/download **queue a server-side single-file job**. File bytes do not
109
+ pass through Python. A successful command returns a job ID, not a completed
110
+ transfer. Inspect `transfer list` for queued/active/done/error/canceled states.
111
+ Exiting or interrupting the CLI does not cancel submitted work.
112
+ - Upload source paths are resolved to absolute paths and must be regular local
113
+ files. Size comes from the file stat. The destination is a remote directory;
114
+ `--name` changes the destination basename. Directory recursion is not included.
115
+ - Download targets are existing local directories, resolved to absolute paths.
116
+ Use `files list` to obtain the remote `path`, `size` and `mtime` (Unix seconds).
117
+ `--size` is required, including `--size 0` for an empty file. No metadata lookup
118
+ happens implicitly. `--mtime` defaults to zero (unknown). `--overwrite` opts
119
+ into replacing a local destination; otherwise BoFileX chooses a free name.
120
+ Remote upload conflict handling remains the daemon's behavior.
121
+ - Cancel is the daemon's idempotent operation: it may return `status: ok` even
122
+ for an unknown ID. Resume acts on the **same existing job** and its `.part`
123
+ data; the daemon rejects jobs it cannot continue. Queue durability across a
124
+ daemon restart is not promised by this CLI.
125
+ - A message response `sent: true` means **accepted for asynchronous delivery**.
126
+ The daemon may subsequently queue/retry delivery or record a refusal.
127
+ - `channels list PEER_ID` can focus the engine on that peer when no UI socket
128
+ holds focus. An open UI socket may retain focus instead; check the response's
129
+ `peer`. `channels select` and `channels mode` change the **shared currently
130
+ focused engine**, not a per-command or per-peer isolated session. Selecting
131
+ `auto` sends an empty pin; selecting all directions clears directional pins.
132
+ A pin may fall back to another ready path. `direct`/`relay` modes clear pins.
133
+ - `files list` can update the daemon's remembered remote pane directory.
134
+ `peers` may refresh discovery. These are existing API side effects.
135
+
136
+ ## Output, errors and API mapping
137
+
138
+ `--json` prints the API response as one JSON value on stdout. Human output is
139
+ pretty JSON except for explicit message submission/transfer queue acknowledgments.
140
+ Operational errors use stderr (a JSON `{"error": "..."}` object with `--json`),
141
+ with no traceback or HTTP response body dump. API `error` fields fail even when
142
+ HTTP status is 200. HTTP responses are limited to 8 MiB.
143
+
144
+ Exit codes: `0` success, `1` operational/API error, `2` argparse usage error,
145
+ `130` interrupted. Argparse usage errors remain standard text, including with
146
+ `--json`. A timeout or lost connection does not prove that a mutation failed;
147
+ check daemon state before submitting it again.
148
+
149
+ | Command | Local route | Method / query |
150
+ | --- | --- | --- |
151
+ | `info` | `/api/info` | GET |
152
+ | `peers` | `/api/peers` | GET |
153
+ | `message send` | `/api/peers/chat/send` | POST; peer, text |
154
+ | `files list` | `/api/remote/listdir` | GET; peer, path |
155
+ | `transfer list` | `/api/transfers` | GET |
156
+ | `transfer upload/download` | `/api/transfer/add` | POST; peer, peername, dir=up/down, src, target, name, size; download: mtime, overwrite |
157
+ | `transfer cancel/resume` | `/api/transfer/cancel`, `/api/transfer/resume` | POST; id |
158
+ | `channels list` | `/api/peers/channels` | GET; peer |
159
+ | `channels select` | `/api/peers/channels/select` | POST; name, dir=empty/up/down |
160
+ | `channels mode` | `/api/peers/channels/mode` | POST; mode=auto/direct/relay |
161
+
162
+ Mutations use POST with **URL query parameters and an empty body**, matching
163
+ the Go handlers' `r.URL.Query()` implementation. Do not log full request URLs
164
+ if message content or filesystem paths are sensitive. Peer IDs and paths are
165
+ encoded query values and can never change the HTTP destination.
166
+
167
+ ## Test, build and release handoff
168
+
169
+ From this directory (PowerShell):
170
+
171
+ ```powershell
172
+ $env:PYTHONPATH = "$PWD/src"
173
+ python -m unittest discover -s tests -v
174
+ python -m build
175
+ ```
176
+
177
+ Tests use only ephemeral loopback mock HTTP servers and temporary files. No
178
+ test contacts a running BoFileX, reads real tokens, starts/stops services, or
179
+ publishes anything. Coverage includes route/query contracts, file metadata,
180
+ Unicode escaping, cookie auth, redirects, proxy isolation, URL restrictions,
181
+ JSON/API errors, and validation before any mutation.
182
+
183
+ `python -m build` creates `dist/bofile-0.1.0-py3-none-any.whl` and
184
+ `dist/bofile-0.1.0.tar.gz`. Setuptools is a build dependency only. The sdist
185
+ includes the tests. Review package metadata, ownership/licensing and release
186
+ version before publication; PyPI name availability does not reserve a name.
187
+ The release owner handles PyPI credentials, final validation and publishing.
188
+ This implementation deliberately does not upload to PyPI or TestPyPI.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/bofile/__init__.py
6
+ src/bofile/__main__.py
7
+ src/bofile/cli.py
8
+ src/bofile/client.py
9
+ src/bofile.egg-info/PKG-INFO
10
+ src/bofile.egg-info/SOURCES.txt
11
+ src/bofile.egg-info/dependency_links.txt
12
+ src/bofile.egg-info/entry_points.txt
13
+ src/bofile.egg-info/top_level.txt
14
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bofile = bofile.cli:main
@@ -0,0 +1 @@
1
+ bofile
@@ -0,0 +1,326 @@
1
+ """All HTTP traffic goes to this suite's ephemeral mock server, never BoFileX."""
2
+
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import threading
9
+ import unittest
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+ from unittest.mock import patch
13
+ from urllib.parse import parse_qs, urlsplit
14
+
15
+ from bofile.cli import main
16
+ from bofile.client import BofileError, Client, validate_base_url
17
+
18
+
19
+ class Handler(BaseHTTPRequestHandler):
20
+ def log_message(self, *args):
21
+ pass
22
+
23
+ def do_GET(self):
24
+ self.dispatch()
25
+
26
+ def do_POST(self):
27
+ self.dispatch()
28
+
29
+ def dispatch(self):
30
+ parsed = urlsplit(self.path)
31
+ self.server.calls.append({
32
+ "method": self.command, "path": parsed.path,
33
+ "query": parse_qs(parsed.query, keep_blank_values=True),
34
+ "headers": dict(self.headers),
35
+ "body": self.rfile.read(int(self.headers.get("Content-Length", 0))),
36
+ })
37
+ self.send_response(self.server.status)
38
+ if self.server.location:
39
+ self.send_header("Location", self.server.location)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.end_headers()
42
+ self.wfile.write(self.server.payload)
43
+
44
+
45
+ class CLITests(unittest.TestCase):
46
+ @classmethod
47
+ def setUpClass(cls):
48
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
49
+ cls.base = f"http://127.0.0.1:{cls.server.server_port}"
50
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
51
+ cls.thread.start()
52
+
53
+ @classmethod
54
+ def tearDownClass(cls):
55
+ cls.server.shutdown()
56
+ cls.server.server_close()
57
+ cls.thread.join()
58
+
59
+ def setUp(self):
60
+ self.server.calls = []
61
+ self.server.status = 200
62
+ self.server.location = None
63
+ self.server.payload = b'{"status":"ok"}'
64
+ self.tmp = tempfile.TemporaryDirectory()
65
+ self.addCleanup(self.tmp.cleanup)
66
+ self.folder = Path(self.tmp.name)
67
+
68
+ def run_cli(self, *args, root_options=()):
69
+ out, err = io.StringIO(), io.StringIO()
70
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
71
+ code = main(["--base-url", self.base, *root_options, *args])
72
+ return code, out.getvalue(), err.getvalue()
73
+
74
+ def query(self):
75
+ return self.server.calls[-1]["query"]
76
+
77
+ def test_read_routes_and_json(self):
78
+ for command, route in (("info", "/api/info"), ("peers", "/api/peers")):
79
+ with self.subTest(command=command):
80
+ self.server.payload = json.dumps({command: []}).encode()
81
+ code, out, err = self.run_cli(command, "--json")
82
+ self.assertEqual((code, err), (0, ""))
83
+ self.assertEqual(json.loads(out), {command: []})
84
+ self.assertEqual(self.server.calls[-1]["path"], route)
85
+ self.assertEqual(self.server.calls[-1]["method"], "GET")
86
+
87
+ def test_options_at_all_levels(self):
88
+ code, out, err = self.run_cli("transfer", "--json", "list")
89
+ self.assertEqual((code, err), (0, ""))
90
+ self.assertEqual(json.loads(out), {"status": "ok"})
91
+ code, out, err = self.run_cli("transfer", "list", root_options=("--json",))
92
+ self.assertEqual(code, 0)
93
+ self.assertEqual(json.loads(out), {"status": "ok"})
94
+
95
+ def test_message_unicode_and_query_injection_are_encoded(self):
96
+ self.server.payload = b'{"sent":true,"id":"message-1"}'
97
+ text = "你好 &peer=attacker #/? plus+\nline"
98
+ code, out, err = self.run_cli("message", "send", "peer-1", text)
99
+ self.assertEqual((code, err), (0, ""))
100
+ self.assertIn("asynchronous", out)
101
+ self.assertEqual(self.query(), {"peer": ["peer-1"], "text": [text]})
102
+ call = self.server.calls[-1]
103
+ self.assertEqual((call["method"], call["path"], call["body"]),
104
+ ("POST", "/api/peers/chat/send", b""))
105
+
106
+ def test_upload_uses_local_file_metadata_and_server_job(self):
107
+ source = self.folder / "报告 &1.txt"
108
+ source.write_bytes(b"hello")
109
+ self.server.payload = b'{"id":42}'
110
+ code, out, err = self.run_cli("transfer", "upload", "peer-1", str(source),
111
+ "/remote &folder", "--peer-name", "Alice")
112
+ self.assertEqual((code, err), (0, ""))
113
+ self.assertIn("queued", out)
114
+ self.assertEqual(self.server.calls[-1]["path"], "/api/transfer/add")
115
+ self.assertEqual(self.query(), {"dir": ["up"], "src": [str(source.resolve())],
116
+ "target": ["/remote &folder"], "size": ["5"], "peer": ["peer-1"],
117
+ "peername": ["Alice"], "name": [source.name]})
118
+ self.assertEqual(source.read_bytes(), b"hello")
119
+
120
+ def test_download_exact_contract_and_no_local_write(self):
121
+ code, out, err = self.run_cli("transfer", "download", "peer-1", "D:\\共享\\报告.pdf",
122
+ str(self.folder), "--size", "0", "--mtime", "1700000000", "--overwrite", "--json")
123
+ self.assertEqual((code, err), (0, ""))
124
+ self.assertEqual(self.query(), {"dir": ["down"], "src": ["D:\\共享\\报告.pdf"],
125
+ "target": [str(self.folder.resolve())], "size": ["0"], "mtime": ["1700000000"],
126
+ "overwrite": ["1"], "peer": ["peer-1"], "peername": [""], "name": ["报告.pdf"]})
127
+ self.assertEqual(list(self.folder.iterdir()), [])
128
+
129
+ def test_download_rename_without_overwrite(self):
130
+ code, _, _ = self.run_cli("transfer", "download", "p", "/one.txt", str(self.folder),
131
+ "--size", "10", "--name", "two.txt")
132
+ self.assertEqual(code, 0)
133
+ self.assertEqual(self.query()["overwrite"], ["0"])
134
+ self.assertEqual(self.query()["name"], ["two.txt"])
135
+
136
+ def test_invalid_local_paths_do_not_send(self):
137
+ cases = [
138
+ ("transfer", "upload", "p", str(self.folder), "."),
139
+ ("transfer", "upload", "p", str(self.folder / "missing"), "."),
140
+ ("transfer", "download", "p", "file", str(self.folder / "missing"), "--size", "1"),
141
+ ("transfer", "download", "p", "file", str(self.folder), "--size", "1", "--name", "../escape"),
142
+ ("transfer", "download", "p", "file", str(self.folder), "--size", "1", "--name", "C:escape"),
143
+ ]
144
+ for args in cases:
145
+ with self.subTest(args=args):
146
+ code, out, err = self.run_cli(*args)
147
+ self.assertEqual(code, 1)
148
+ self.assertEqual(out, "")
149
+ self.assertIn("bofile:", err)
150
+ self.assertEqual(self.server.calls, [])
151
+
152
+ def test_transfer_list_cancel_resume(self):
153
+ for action, route in (("list", "/api/transfers"), ("cancel", "/api/transfer/cancel"),
154
+ ("resume", "/api/transfer/resume")):
155
+ with self.subTest(action=action):
156
+ args = ("transfer", action) + (() if action == "list" else ("42",))
157
+ code, _, err = self.run_cli(*args)
158
+ self.assertEqual((code, err), (0, ""))
159
+ self.assertEqual(self.server.calls[-1]["path"], route)
160
+ self.assertEqual(self.query(), {} if action == "list" else {"id": ["42"]})
161
+ self.assertEqual(self.server.calls[-1]["method"], "GET" if action == "list" else "POST")
162
+
163
+ def test_channels_contract(self):
164
+ cases = [
165
+ (("channels", "list", "peer-1"), "/api/peers/channels", {"peer": ["peer-1"]}),
166
+ (("channels", "select", "auto"), "/api/peers/channels/select", {"name": [""], "dir": [""]}),
167
+ (("channels", "select", "nat", "--direction", "up"), "/api/peers/channels/select", {"name": ["nat"], "dir": ["up"]}),
168
+ (("channels", "mode", "relay"), "/api/peers/channels/mode", {"mode": ["relay"]}),
169
+ ]
170
+ for args, route, query in cases:
171
+ with self.subTest(args=args):
172
+ code, _, err = self.run_cli(*args)
173
+ self.assertEqual((code, err), (0, ""))
174
+ self.assertEqual(self.server.calls[-1]["path"], route)
175
+ self.assertEqual(self.query(), query)
176
+
177
+ def test_remote_metadata(self):
178
+ code, _, _ = self.run_cli("files", "list", "p", "/shared &stuff")
179
+ self.assertEqual(code, 0)
180
+ self.assertEqual(self.server.calls[-1]["path"], "/api/remote/listdir")
181
+ self.assertEqual(self.query(), {"peer": ["p"], "path": ["/shared &stuff"]})
182
+
183
+ def test_token_cookie_and_no_browser_headers(self):
184
+ token = "a" * 32
185
+ token_file = self.folder / "ui-session.token"
186
+ token_file.write_text(token + "\n")
187
+ code, _, err = self.run_cli("info", "--token-file", str(token_file))
188
+ self.assertEqual((code, err), (0, ""))
189
+ headers = self.server.calls[-1]["headers"]
190
+ self.assertEqual(headers["Cookie"], f"bofile_ui_{self.server.server_port}={token}")
191
+ for name in ("Origin", "Referer", "Sec-Fetch-Site", "Authorization"):
192
+ self.assertNotIn(name, headers)
193
+ self.assertEqual(token_file.read_text(), token + "\n")
194
+
195
+ def test_default_does_not_read_or_create_state(self):
196
+ with patch("pathlib.Path.open", side_effect=AssertionError("unexpected state read")):
197
+ with patch.dict(os.environ, {"BOFILE_STATE_DIR": str(self.folder)}):
198
+ code, _, _ = self.run_cli("info")
199
+ self.assertEqual(code, 0)
200
+ self.assertNotIn("Cookie", self.server.calls[-1]["headers"])
201
+ self.assertEqual(list(self.folder.iterdir()), [])
202
+
203
+ def test_invalid_destination_precedes_credential_read(self):
204
+ with patch("pathlib.Path.open", side_effect=AssertionError("must not read token")):
205
+ with self.assertRaises(BofileError):
206
+ Client("https://evil.example", token_file="ui-session.token")
207
+
208
+ def test_bad_token_files(self):
209
+ token_file = self.folder / "token"
210
+ for token in ("", "a" * 4097, "a" * 32 + ";evil=yes", "a" * 32 + "\r\nX: evil"):
211
+ token_file.write_text(token)
212
+ with self.subTest(token=token[:40]):
213
+ self.assertEqual(self.run_cli("info", "--token-file", str(token_file))[0], 1)
214
+ self.assertEqual(self.run_cli("info", "--token-file", str(self.folder / "missing"))[0], 1)
215
+ self.assertEqual(self.server.calls, [])
216
+
217
+ def test_api_error_at_http_200_and_json_error_redaction(self):
218
+ token = "b" * 32
219
+ token_file = self.folder / "token"
220
+ token_file.write_text(token)
221
+ self.server.payload = json.dumps({"error": "refused\x1b " + token}).encode()
222
+ code, out, err = self.run_cli("transfer", "resume", "4", "--json", "--token-file", str(token_file))
223
+ self.assertEqual((code, out), (1, ""))
224
+ self.assertNotIn(token, err)
225
+ self.assertIn("[redacted]", json.loads(err)["error"])
226
+ self.assertNotIn("\x1b", json.loads(err)["error"])
227
+
228
+ def test_http_errors_hide_bodies_and_credentials(self):
229
+ for status in (401, 403, 404, 500):
230
+ self.server.status = status
231
+ self.server.payload = b"secret in error body"
232
+ code, out, err = self.run_cli("info", "--json")
233
+ self.assertEqual((code, out), (1, ""))
234
+ self.assertIn(str(status), json.loads(err)["error"])
235
+ self.assertNotIn("secret", err)
236
+
237
+ def test_redirect_never_contacts_destination(self):
238
+ sink = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
239
+ sink.calls = []
240
+ thread = threading.Thread(target=sink.serve_forever, daemon=True)
241
+ thread.start()
242
+ try:
243
+ token_file = self.folder / "token"
244
+ token_file.write_text("c" * 32)
245
+ for status in (301, 302, 303, 307, 308):
246
+ self.server.status = status
247
+ self.server.location = f"http://127.0.0.1:{sink.server_port}/leak"
248
+ code, _, err = self.run_cli("info", "--token-file", str(token_file))
249
+ self.assertEqual(code, 1)
250
+ self.assertIn("redirect refused", err)
251
+ self.assertEqual(sink.calls, [])
252
+ finally:
253
+ sink.shutdown()
254
+ sink.server_close()
255
+ thread.join()
256
+
257
+ def test_environment_proxy_is_ignored(self):
258
+ with patch.dict(os.environ, {"http_proxy": "http://127.0.0.1:1", "HTTP_PROXY": "http://127.0.0.1:1", "no_proxy": "", "NO_PROXY": ""}):
259
+ code, _, _ = self.run_cli("info")
260
+ self.assertEqual(code, 0)
261
+ self.assertEqual(len(self.server.calls), 1)
262
+
263
+ def test_bad_json_and_shape(self):
264
+ for payload in (b"", b"<html>not the API</html>", b"\xff", b"null", b"42", b'"string"'):
265
+ self.server.payload = payload
266
+ code, out, err = self.run_cli("info")
267
+ self.assertEqual((code, out), (1, ""))
268
+ self.assertIn("JSON", err)
269
+
270
+ def test_response_limit(self):
271
+ with patch("bofile.client.MAX_RESPONSE", 8):
272
+ code, _, err = self.run_cli("info")
273
+ self.assertEqual(code, 1)
274
+ self.assertIn("exceeds", err)
275
+
276
+ def test_connection_error_and_timeout_no_retry(self):
277
+ from http.client import IncompleteRead
278
+ from urllib.error import URLError
279
+ client = Client(self.base)
280
+ for error in (URLError("secret"), TimeoutError(), IncompleteRead(b"partial")):
281
+ with patch.object(client.opener, "open", side_effect=error) as opened:
282
+ with self.assertRaises(BofileError) as caught:
283
+ client.request("/api/info")
284
+ self.assertNotIn("secret", str(caught.exception))
285
+ self.assertEqual(opened.call_count, 1)
286
+
287
+ def test_invalid_routes_are_rejected(self):
288
+ for path in ("https://evil.example/api/info", "//evil.example", "/api/info?x=y", "/api/../info"):
289
+ with self.assertRaises(BofileError):
290
+ Client(self.base).request(path)
291
+ self.assertEqual(self.server.calls, [])
292
+
293
+ def test_parser_rejections_do_not_send(self):
294
+ cases = [("transfer", "cancel", "0"), ("transfer", "resume", "-1"),
295
+ ("transfer", "download", "p", "file", "."),
296
+ ("transfer", "download", "p", "file", ".", "--size", "-1"),
297
+ ("transfer", "cancel", str(2**63)), ("message", "send", "p", " "),
298
+ ("info", "--timeout", "nan"), ("info", "--timeout", "0")]
299
+ for args in cases:
300
+ with contextlib.redirect_stderr(io.StringIO()):
301
+ with self.assertRaises(SystemExit) as caught:
302
+ self.run_cli(*args)
303
+ self.assertEqual(caught.exception.code, 2)
304
+ self.assertEqual(self.server.calls, [])
305
+
306
+
307
+ class URLTests(unittest.TestCase):
308
+ def test_numeric_loopback_only(self):
309
+ for value, port in (("http://127.0.0.1:34120", 34120),
310
+ ("http://[::1]:34120/", 34120), ("https://127.0.0.1", 443)):
311
+ with self.subTest(value=value):
312
+ self.assertEqual(validate_base_url(value), (value.rstrip("/"), port))
313
+ for value in ("http://localhost:34120", "http://evil.example", "http://192.168.1.1",
314
+ "http://0.0.0.0", "file:///tmp/token", "http://127.0.0.1@evil.example",
315
+ "http://user:pass@127.0.0.1", "http://127.0.0.1/api/info",
316
+ "http://127.0.0.1?x=1", "http://127.0.0.1#frag", "http://127.0.0.1?",
317
+ "http://127.0.0.1#", "http://127.0.0.1:", "http://127.0.0.1:0",
318
+ "http://127.0.0.1:65536", "http://2130706433", "http://127.1",
319
+ "http://[::ffff:127.0.0.1]", "http://[::1%25lo0]", "\nhttp://127.0.0.1"):
320
+ with self.subTest(value=value):
321
+ with self.assertRaises(BofileError):
322
+ validate_base_url(value)
323
+
324
+
325
+ if __name__ == "__main__":
326
+ unittest.main()