xwing 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.
xwing-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: xwing
3
+ Version: 0.1.0
4
+ Summary: File sharing server with WebDAV support
5
+ Author-email: Anudeep Dhavaleswarapu <anudeepd2@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/anudeepd/xwing
8
+ Project-URL: Repository, https://github.com/anudeepd/xwing
9
+ Project-URL: Issues, https://github.com/anudeepd/xwing/issues
10
+ Keywords: webdav,fileserver,upload,sftp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.111.0
21
+ Requires-Dist: uvicorn[standard]>=0.29.0
22
+ Requires-Dist: anyio>=4.0
23
+ Requires-Dist: click>=8.1.0
24
+ Requires-Dist: jinja2>=3.1.0
25
+ Requires-Dist: python-multipart>=0.0.9
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
29
+ Requires-Dist: httpx>=0.28.1; extra == "dev"
30
+ Provides-Extra: ldap
31
+ Requires-Dist: ldapgate; extra == "ldap"
32
+
33
+ <p align="center">
34
+ <img src="https://raw.githubusercontent.com/anudeepd/xwing/main/assets/logo.svg" alt="Xwing" width="120"/>
35
+ </p>
36
+
37
+ <h1 align="center">Xwing</h1>
38
+
39
+ <p align="center">A self-contained file sharing server with WebDAV support. Works out of the box or integrates with LDAPGate for corporate LDAP/AD authentication.</p>
40
+
41
+ ## Features
42
+
43
+ - **WebDAV server** — mount as a drive on Windows, macOS, and Linux using native WebDAV clients
44
+ - **Resumable uploads** — chunked uploads with session recovery; supports large files (up to 10 GB)
45
+ - **Browser-based file browser** — drag-and-drop upload, directory creation, zip download, file delete
46
+ - **In-browser text editor** — CodeMirror-powered editor for text files (configurable via `_EDITABLE_EXTS`); files over 2 MB are not editable
47
+ - **WebDAV COPY / MOVE** — server-side file and directory copy/move via `Destination` header
48
+ - **Optional LDAP / AD authentication** — via [LDAPGate](https://github.com/anudeepd/ldapgate)
49
+ - **Single self-contained wheel** — no external CDN dependencies; fonts embedded as base64 WOFF2
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install xwing
55
+ ```
56
+
57
+ For LDAP/AD authentication:
58
+
59
+ ```bash
60
+ pip install 'xwing[ldap]'
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ xwing serve --root /path/to/serve
67
+ ```
68
+
69
+ Opens the file browser at `http://127.0.0.1:8989` and launches your default browser.
70
+
71
+ Options:
72
+
73
+ ```
74
+ --root PATH Root directory to serve. [required]
75
+ --host TEXT Bind host. [default: 127.0.0.1]
76
+ --port INTEGER Bind port. [default: 8989]
77
+ --no-open Don't open the browser automatically.
78
+ --max-upload-gb NUM Max upload size in GB. [default: 10]
79
+ --require-auth Require X-Forwarded-User header (403 if missing).
80
+ --user-header TEXT Header to read username from. [default: X-Forwarded-User]
81
+ --reload Auto-reload on code changes (dev only).
82
+ --ldap-config PATH Path to LDAPGate YAML config to enable LDAP authentication.
83
+ ```
84
+
85
+ ### WebDAV Mount Examples
86
+
87
+ **Linux (DAVfs2):**
88
+ ```bash
89
+ sudo mount.davfs http://localhost:8989 /mnt/xwing -o username=<user>
90
+ ```
91
+
92
+ **macOS:**
93
+ ```bash
94
+ open http://localhost:8989
95
+ # Or mount: Finder → Go → Connect to Server → http://localhost:8989
96
+ ```
97
+
98
+ **Windows (native WebDAV):**
99
+ ```
100
+ net use Z: \\localhost@8989\DavWWWRoot /persistent:yes
101
+ ```
102
+
103
+ ### Resumable Upload (Chunked)
104
+
105
+ For large files, use the chunked upload API:
106
+
107
+ ```bash
108
+ # 1. Init session
109
+ curl -X POST http://localhost:8989/_upload/init \
110
+ -H "Content-Type: application/json" \
111
+ -d '{"filename": "big.iso", "total_chunks": 100, "dir": "/"}'
112
+
113
+ # 2. Upload each chunk
114
+ curl -X PUT http://localhost:8989/_upload/<session_id>/<chunk_index> \
115
+ --data-binary @chunk.part
116
+
117
+ # 3. Complete
118
+ curl -X POST http://localhost:8989/_upload/<session_id>/complete
119
+ ```
120
+
121
+ ## LDAP / Active Directory Authentication
122
+
123
+ Xwing supports two modes for LDAP/AD auth:
124
+
125
+ **Mode 1 — Standalone proxy:** Run LDAPGate as a reverse proxy in front of xwing. Authenticated requests get an `X-Forwarded-User` header that xwing reads.
126
+
127
+ ```
128
+ Browser → LDAPGate → xwing
129
+ ```
130
+
131
+ ```bash
132
+ ldapgate serve --config ldapgate.yaml
133
+ xwing serve --root /data --require-auth
134
+ ```
135
+
136
+ **Mode 2 — Built-in middleware:** Inject LDAPGate directly into xwing as FastAPI middleware:
137
+
138
+ ```bash
139
+ pip install 'xwing[ldap]'
140
+ xwing serve --root /data --ldap-config /path/to/ldapgate.yaml
141
+ ```
142
+
143
+ See the [LDAPGate README](https://github.com/anudeepd/ldapgate) for config file documentation.
144
+
145
+ ## Development
146
+
147
+ Requires [uv](https://github.com/astral-sh/uv).
148
+
149
+ ```bash
150
+ git clone https://github.com/anudeepd/xwing
151
+ cd xwing
152
+ uv sync
153
+ uv run xwing serve --root .
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT
xwing-0.1.0/README.md ADDED
@@ -0,0 +1,126 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/anudeepd/xwing/main/assets/logo.svg" alt="Xwing" width="120"/>
3
+ </p>
4
+
5
+ <h1 align="center">Xwing</h1>
6
+
7
+ <p align="center">A self-contained file sharing server with WebDAV support. Works out of the box or integrates with LDAPGate for corporate LDAP/AD authentication.</p>
8
+
9
+ ## Features
10
+
11
+ - **WebDAV server** — mount as a drive on Windows, macOS, and Linux using native WebDAV clients
12
+ - **Resumable uploads** — chunked uploads with session recovery; supports large files (up to 10 GB)
13
+ - **Browser-based file browser** — drag-and-drop upload, directory creation, zip download, file delete
14
+ - **In-browser text editor** — CodeMirror-powered editor for text files (configurable via `_EDITABLE_EXTS`); files over 2 MB are not editable
15
+ - **WebDAV COPY / MOVE** — server-side file and directory copy/move via `Destination` header
16
+ - **Optional LDAP / AD authentication** — via [LDAPGate](https://github.com/anudeepd/ldapgate)
17
+ - **Single self-contained wheel** — no external CDN dependencies; fonts embedded as base64 WOFF2
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install xwing
23
+ ```
24
+
25
+ For LDAP/AD authentication:
26
+
27
+ ```bash
28
+ pip install 'xwing[ldap]'
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ xwing serve --root /path/to/serve
35
+ ```
36
+
37
+ Opens the file browser at `http://127.0.0.1:8989` and launches your default browser.
38
+
39
+ Options:
40
+
41
+ ```
42
+ --root PATH Root directory to serve. [required]
43
+ --host TEXT Bind host. [default: 127.0.0.1]
44
+ --port INTEGER Bind port. [default: 8989]
45
+ --no-open Don't open the browser automatically.
46
+ --max-upload-gb NUM Max upload size in GB. [default: 10]
47
+ --require-auth Require X-Forwarded-User header (403 if missing).
48
+ --user-header TEXT Header to read username from. [default: X-Forwarded-User]
49
+ --reload Auto-reload on code changes (dev only).
50
+ --ldap-config PATH Path to LDAPGate YAML config to enable LDAP authentication.
51
+ ```
52
+
53
+ ### WebDAV Mount Examples
54
+
55
+ **Linux (DAVfs2):**
56
+ ```bash
57
+ sudo mount.davfs http://localhost:8989 /mnt/xwing -o username=<user>
58
+ ```
59
+
60
+ **macOS:**
61
+ ```bash
62
+ open http://localhost:8989
63
+ # Or mount: Finder → Go → Connect to Server → http://localhost:8989
64
+ ```
65
+
66
+ **Windows (native WebDAV):**
67
+ ```
68
+ net use Z: \\localhost@8989\DavWWWRoot /persistent:yes
69
+ ```
70
+
71
+ ### Resumable Upload (Chunked)
72
+
73
+ For large files, use the chunked upload API:
74
+
75
+ ```bash
76
+ # 1. Init session
77
+ curl -X POST http://localhost:8989/_upload/init \
78
+ -H "Content-Type: application/json" \
79
+ -d '{"filename": "big.iso", "total_chunks": 100, "dir": "/"}'
80
+
81
+ # 2. Upload each chunk
82
+ curl -X PUT http://localhost:8989/_upload/<session_id>/<chunk_index> \
83
+ --data-binary @chunk.part
84
+
85
+ # 3. Complete
86
+ curl -X POST http://localhost:8989/_upload/<session_id>/complete
87
+ ```
88
+
89
+ ## LDAP / Active Directory Authentication
90
+
91
+ Xwing supports two modes for LDAP/AD auth:
92
+
93
+ **Mode 1 — Standalone proxy:** Run LDAPGate as a reverse proxy in front of xwing. Authenticated requests get an `X-Forwarded-User` header that xwing reads.
94
+
95
+ ```
96
+ Browser → LDAPGate → xwing
97
+ ```
98
+
99
+ ```bash
100
+ ldapgate serve --config ldapgate.yaml
101
+ xwing serve --root /data --require-auth
102
+ ```
103
+
104
+ **Mode 2 — Built-in middleware:** Inject LDAPGate directly into xwing as FastAPI middleware:
105
+
106
+ ```bash
107
+ pip install 'xwing[ldap]'
108
+ xwing serve --root /data --ldap-config /path/to/ldapgate.yaml
109
+ ```
110
+
111
+ See the [LDAPGate README](https://github.com/anudeepd/ldapgate) for config file documentation.
112
+
113
+ ## Development
114
+
115
+ Requires [uv](https://github.com/astral-sh/uv).
116
+
117
+ ```bash
118
+ git clone https://github.com/anudeepd/xwing
119
+ cd xwing
120
+ uv sync
121
+ uv run xwing serve --root .
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xwing"
7
+ version = "0.1.0"
8
+ description = "File sharing server with WebDAV support"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "Anudeep Dhavaleswarapu", email = "anudeepd2@gmail.com"}]
13
+ keywords = ["webdav", "fileserver", "upload", "sftp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Web Environment",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+
24
+ dependencies = [
25
+ "fastapi>=0.111.0",
26
+ "uvicorn[standard]>=0.29.0",
27
+ "anyio>=4.0",
28
+ "click>=8.1.0",
29
+ "jinja2>=3.1.0",
30
+ "python-multipart>=0.0.9",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.0",
36
+ "pytest-asyncio>=0.21",
37
+ "httpx>=0.28.1",
38
+ ]
39
+ ldap = ["ldapgate"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/anudeepd/xwing"
43
+ Repository = "https://github.com/anudeepd/xwing"
44
+ Issues = "https://github.com/anudeepd/xwing/issues"
45
+
46
+ [project.scripts]
47
+ xwing = "xwing.cli:main"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["."]
51
+ include = ["xwing*"]
52
+
53
+ [tool.setuptools.package-data]
54
+ xwing = ["static/**/*", "static/*", "templates/*"]
55
+
56
+ [tool.pytest.ini_options]
57
+ asyncio_mode = "auto"
xwing-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,305 @@
1
+ import io
2
+ import zipfile
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from xwing.app import create_app
9
+ from xwing.config import Settings
10
+
11
+
12
+ HTML = {"Accept": "text/html"}
13
+
14
+
15
+ class TestDirectoryListing:
16
+ def test_root_listing(self, client, root):
17
+ (root / "hello.txt").write_text("hi")
18
+ (root / "subdir").mkdir()
19
+ r = client.get("/", headers=HTML)
20
+ assert r.status_code == 200
21
+ assert "hello.txt" in r.text
22
+ assert "subdir" in r.text
23
+
24
+ def test_subdir_listing(self, client, root):
25
+ d = root / "docs"
26
+ d.mkdir()
27
+ (d / "readme.md").write_text("docs")
28
+ r = client.get("/docs/", headers=HTML)
29
+ assert r.status_code == 200
30
+ assert "readme.md" in r.text
31
+
32
+ def test_missing_dir_returns_404(self, client):
33
+ r = client.get("/nonexistent/", headers=HTML)
34
+ assert r.status_code == 404
35
+
36
+
37
+ class TestFileDownload:
38
+ def test_download_file(self, client, root):
39
+ (root / "data.txt").write_text("content here")
40
+ r = client.get("/data.txt")
41
+ assert r.status_code == 200
42
+ assert r.text == "content here"
43
+
44
+ def test_missing_file_returns_404(self, client):
45
+ r = client.get("/nope.txt")
46
+ assert r.status_code == 404
47
+
48
+
49
+ class TestAuth:
50
+ def test_require_auth_blocks_anonymous(self, root, tmp_dir):
51
+ s = Settings(root_dir=root, tmp_dir=tmp_dir, require_auth=True)
52
+ with TestClient(create_app(s)) as c:
53
+ r = c.get("/")
54
+ assert r.status_code == 403
55
+
56
+ def test_require_auth_passes_with_header(self, root, tmp_dir):
57
+ s = Settings(root_dir=root, tmp_dir=tmp_dir, require_auth=True)
58
+ with TestClient(create_app(s)) as c:
59
+ r = c.get("/", headers={**HTML, "X-Forwarded-User": "alice"})
60
+ assert r.status_code == 200
61
+
62
+ def test_no_auth_returns_anonymous(self, client, root):
63
+ r = client.get("/", headers=HTML)
64
+ assert r.status_code == 200
65
+ assert "anonymous" in r.text
66
+
67
+ def test_custom_user_header(self, root, tmp_dir):
68
+ s = Settings(root_dir=root, tmp_dir=tmp_dir, user_header="X-Remote-User")
69
+ with TestClient(create_app(s)) as c:
70
+ r = c.get("/", headers={**HTML, "X-Remote-User": "bob"})
71
+ assert r.status_code == 200
72
+ assert "bob" in r.text
73
+
74
+
75
+ class TestPut:
76
+ def test_put_creates_file(self, client, root):
77
+ r = client.put("/newfile.txt", content=b"hello")
78
+ assert r.status_code == 204
79
+ assert (root / "newfile.txt").read_bytes() == b"hello"
80
+
81
+ def test_put_overwrites_file(self, client, root):
82
+ (root / "existing.txt").write_text("old")
83
+ r = client.put("/existing.txt", content=b"new")
84
+ assert r.status_code == 204
85
+ assert (root / "existing.txt").read_bytes() == b"new"
86
+
87
+ def test_put_on_directory_returns_409(self, client, root):
88
+ (root / "adir").mkdir()
89
+ r = client.put("/adir", content=b"data")
90
+ assert r.status_code == 409
91
+
92
+ def test_put_exceeds_max_upload_bytes_returns_413(self, root, tmp_dir):
93
+ s = Settings(root_dir=root, tmp_dir=tmp_dir, max_upload_bytes=10)
94
+ with TestClient(create_app(s)) as c:
95
+ r = c.put("/large.txt", content=b"x" * 100)
96
+ assert r.status_code == 413
97
+ assert not (root / "large.txt").exists()
98
+
99
+
100
+ class TestHead:
101
+ def test_head_on_file_returns_headers(self, client, root):
102
+ (root / "data.txt").write_text("content")
103
+ r = client.head("/data.txt")
104
+ assert r.status_code == 200
105
+
106
+ def test_head_on_dir_returns_200(self, client, root):
107
+ (root / "subdir").mkdir()
108
+ r = client.head("/")
109
+ assert r.status_code == 200
110
+
111
+ def test_head_does_not_return_body(self, client, root):
112
+ (root / "data.txt").write_text("content")
113
+ r = client.head("/data.txt")
114
+ assert r.content == b""
115
+
116
+
117
+ class TestZip:
118
+ def test_zip_directory(self, client, root):
119
+ (root / "a.txt").write_text("hello")
120
+ (root / "sub").mkdir()
121
+ (root / "sub" / "b.txt").write_text("world")
122
+ r = client.get("/?zip")
123
+ assert r.status_code == 200
124
+ assert r.headers["content-type"] == "application/zip"
125
+ zf = zipfile.ZipFile(io.BytesIO(r.content))
126
+ names = zf.namelist()
127
+ assert "a.txt" in names
128
+ assert "sub/b.txt" in names
129
+
130
+ def test_zip_nested_files(self, client, root):
131
+ (root / "deep").mkdir()
132
+ (root / "deep" / "nested.txt").write_text("deep content")
133
+ r = client.get("/deep/?zip")
134
+ assert r.status_code == 200
135
+ zf = zipfile.ZipFile(io.BytesIO(r.content))
136
+ assert "nested.txt" in zf.namelist()
137
+
138
+ def test_zip_empty_directory(self, client, root):
139
+ (root / "empty").mkdir()
140
+ r = client.get("/empty/?zip")
141
+ assert r.status_code == 200
142
+ assert r.headers["content-type"] == "application/zip"
143
+ zf = zipfile.ZipFile(io.BytesIO(r.content))
144
+ assert len(zf.namelist()) == 0
145
+
146
+
147
+ class TestCopy:
148
+ def test_copy_file(self, client, root):
149
+ (root / "src.txt").write_text("source")
150
+ r = client.request("COPY", "/src.txt", headers={"Destination": "/dst.txt"})
151
+ assert r.status_code == 201
152
+ assert (root / "src.txt").read_text() == "source"
153
+ assert (root / "dst.txt").read_text() == "source"
154
+
155
+ def test_copy_file_overwrite_t(self, client, root):
156
+ (root / "src.txt").write_text("source")
157
+ (root / "dst.txt").write_text("old")
158
+ r = client.request(
159
+ "COPY", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "T"}
160
+ )
161
+ assert r.status_code == 201
162
+ assert (root / "dst.txt").read_text() == "source"
163
+
164
+ def test_copy_file_overwrite_f_returns_412(self, client, root):
165
+ (root / "src.txt").write_text("source")
166
+ (root / "dst.txt").write_text("old")
167
+ r = client.request(
168
+ "COPY", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "F"}
169
+ )
170
+ assert r.status_code == 412
171
+
172
+ def test_copy_missing_source_returns_404(self, client):
173
+ r = client.request("COPY", "/ghost.txt", headers={"Destination": "/dst.txt"})
174
+ assert r.status_code == 404
175
+
176
+
177
+ class TestMove:
178
+ def test_move_file(self, client, root):
179
+ (root / "src.txt").write_text("source")
180
+ r = client.request("MOVE", "/src.txt", headers={"Destination": "/dst.txt"})
181
+ assert r.status_code == 201
182
+ assert not (root / "src.txt").exists()
183
+ assert (root / "dst.txt").read_text() == "source"
184
+
185
+ def test_move_file_overwrite_f_returns_412(self, client, root):
186
+ (root / "src.txt").write_text("source")
187
+ (root / "dst.txt").write_text("old")
188
+ r = client.request(
189
+ "MOVE", "/src.txt", headers={"Destination": "/dst.txt", "Overwrite": "F"}
190
+ )
191
+ assert r.status_code == 412
192
+ assert (root / "src.txt").read_text() == "source"
193
+
194
+
195
+ class TestEnvFile:
196
+ def test_env_file_not_downloadable(self, client, root):
197
+ (root / ".env").write_text("SECRET=hunter2")
198
+ r = client.get("/.env")
199
+ assert r.status_code == 403
200
+
201
+ def test_env_file_variant_not_downloadable(self, client, root):
202
+ (root / ".env.local").write_text("SECRET=local")
203
+ r = client.get("/.env.local")
204
+ assert r.status_code == 403
205
+
206
+
207
+ class TestSymlinkInsideRoot:
208
+ def test_symlink_inside_root_allowed(self, root):
209
+ target = root / "actual.txt"
210
+ target.write_text("hello")
211
+ link = root / "link.txt"
212
+ link.symlink_to(target)
213
+ from xwing.files import safe_path
214
+
215
+ result = safe_path(root, "link.txt")
216
+ assert result.resolve() == target.resolve()
217
+
218
+
219
+ class TestDelete:
220
+ def test_delete_file(self, client, root):
221
+ f = root / "todelete.txt"
222
+ f.write_text("bye")
223
+ r = client.delete("/todelete.txt")
224
+ assert r.status_code == 204
225
+ assert not f.exists()
226
+
227
+ def test_delete_directory(self, client, root):
228
+ d = root / "rmdir"
229
+ d.mkdir()
230
+ (d / "child.txt").write_text("x")
231
+ r = client.delete("/rmdir/")
232
+ assert r.status_code == 204
233
+ assert not d.exists()
234
+
235
+ def test_delete_missing_returns_404(self, client):
236
+ r = client.delete("/ghost.txt")
237
+ assert r.status_code == 404
238
+
239
+
240
+ class TestOptions:
241
+ def test_options_returns_dav_headers(self, client):
242
+ r = client.options("/")
243
+ assert r.status_code == 200
244
+ assert "DAV" in r.headers
245
+ assert "PROPFIND" in r.headers.get("Allow", "")
246
+
247
+
248
+ class TestMkcol:
249
+ def test_mkcol_creates_directory(self, client, root):
250
+ r = client.request("MKCOL", "/newdir")
251
+ assert r.status_code == 201
252
+ assert (root / "newdir").is_dir()
253
+
254
+ def test_mkcol_existing_returns_405(self, client, root):
255
+ (root / "exists").mkdir()
256
+ r = client.request("MKCOL", "/exists")
257
+ assert r.status_code == 405
258
+
259
+ def test_mkcol_missing_parent_returns_409(self, client, root):
260
+ r = client.request("MKCOL", "/parent/child")
261
+ assert r.status_code == 409
262
+
263
+
264
+ class TestPropfind:
265
+ def test_propfind_root(self, client, root):
266
+ (root / "a.txt").write_text("a")
267
+ r = client.request("PROPFIND", "/", headers={"Depth": "1"})
268
+ assert r.status_code == 207
269
+ assert "a.txt" in r.text
270
+
271
+ def test_propfind_depth0(self, client, root):
272
+ r = client.request("PROPFIND", "/", headers={"Depth": "0"})
273
+ assert r.status_code == 207
274
+
275
+ def test_propfind_infinity_rejected(self, client):
276
+ r = client.request("PROPFIND", "/", headers={"Depth": "infinity"})
277
+ assert r.status_code == 403
278
+
279
+ def test_propfind_missing_returns_404(self, client):
280
+ r = client.request("PROPFIND", "/ghost")
281
+ assert r.status_code == 404
282
+
283
+
284
+ class TestLock:
285
+ def test_lock_returns_501(self, client, root):
286
+ (root / "file.txt").write_text("x")
287
+ r = client.request("LOCK", "/file.txt")
288
+ assert r.status_code == 501
289
+
290
+
291
+ class TestPathTraversal:
292
+ def test_traversal_in_url_path_normalized_by_http(self, client):
293
+ # HTTP normalises ../../ in URLs before routing — the path arrives
294
+ # as the normalised form, which simply won't exist under root → 404.
295
+ r = client.get("/../../etc/passwd")
296
+ assert r.status_code == 404
297
+
298
+ def test_traversal_in_destination_rejected(self, client, root):
299
+ (root / "src.txt").write_text("src")
300
+ r = client.request(
301
+ "COPY",
302
+ "/src.txt",
303
+ headers={"Destination": "http://localhost/../../etc/passwd"},
304
+ )
305
+ assert r.status_code == 403