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 +6 -0
- bofile-0.1.0/MANIFEST.in +2 -0
- bofile-0.1.0/PKG-INFO +188 -0
- bofile-0.1.0/README.md +171 -0
- bofile-0.1.0/pyproject.toml +28 -0
- bofile-0.1.0/setup.cfg +4 -0
- bofile-0.1.0/src/bofile/__init__.py +3 -0
- bofile-0.1.0/src/bofile/__main__.py +3 -0
- bofile-0.1.0/src/bofile/cli.py +196 -0
- bofile-0.1.0/src/bofile/client.py +133 -0
- bofile-0.1.0/src/bofile.egg-info/PKG-INFO +188 -0
- bofile-0.1.0/src/bofile.egg-info/SOURCES.txt +14 -0
- bofile-0.1.0/src/bofile.egg-info/dependency_links.txt +1 -0
- bofile-0.1.0/src/bofile.egg-info/entry_points.txt +2 -0
- bofile-0.1.0/src/bofile.egg-info/top_level.txt +1 -0
- bofile-0.1.0/tests/test_cli.py +326 -0
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.
|
bofile-0.1.0/MANIFEST.in
ADDED
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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|