diff --git a/modules/dav/fs/quota.c b/modules/dav/fs/quota.c index 7d239f1c478..19bff2fe2e8 100644 --- a/modules/dav/fs/quota.c +++ b/modules/dav/fs/quota.c @@ -279,9 +279,9 @@ int dav_fs_quota_precondition(request_rec *r, */ if (dst == NULL) goto out; - path = dav_fs_fname(dst); + path = dav_fs_pathname(dst); } else { - path = dav_fs_fname(src); + path = dav_fs_pathname(src); } path = ap_make_dirstr_parent(r->pool, path); diff --git a/test/modules/dav/test_001_propdb_ns_limit.py b/test/modules/dav/test_001_propdb_ns_limit.py index b9d65b8cf19..10fd4035c42 100644 --- a/test/modules/dav/test_001_propdb_ns_limit.py +++ b/test/modules/dav/test_001_propdb_ns_limit.py @@ -49,7 +49,7 @@ def _proppatch(self, env, body: str): '--data-binary', f'@{fpath}']) # a modest number of distinct namespaces is accepted and stored - def test_dav_001_01(self, env): + def test_dav_001_01_simple_distinct_ns(self, env): r = self._proppatch(env, self._propertyupdate(100, 'a')) assert r.response["status"] == 207 assert 'HTTP/1.1 200' in r.stdout @@ -58,7 +58,7 @@ def test_dav_001_01(self, env): # declaring more distinct namespaces than one property database will # record must be refused with 413 rather than accepted, and the resource # must remain usable afterwards - def test_dav_001_02(self, env): + def test_dav_001_02_complex_distinct_ns(self, env): r = self._proppatch(env, self._propertyupdate(2049, 'b')) # the refusal is logged at error level, and writing back the # oversized namespace table on close fails with a warning diff --git a/test/modules/dav/test_004_operations.py b/test/modules/dav/test_004_operations.py new file mode 100644 index 00000000000..b011042a9dc --- /dev/null +++ b/test/modules/dav/test_004_operations.py @@ -0,0 +1,218 @@ +import os +import sys +import shutil + +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestDavOperations: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + dav_dir = os.path.join(env.gen_dir, 'dav-ops') + if os.path.exists(dav_dir): + shutil.rmtree(dav_dir) + os.makedirs(dav_dir, exist_ok=True) + import glob + for f in glob.glob(os.path.join(env.gen_dir, 'davlock-ops*')): + os.remove(f) + with open(os.path.join(dav_dir, 'hello.txt'), 'w') as fd: + fd.write('hello') + dav_dir_conf = dav_dir.replace('\\', '/') + lock_db_conf = os.path.join(env.gen_dir, 'davlock-ops').replace('\\', '/') + conf = HttpdConf(env, extras={ + 'base': f""" + DavLockDB "{lock_db_conf}" + """, + f"test1.{env.http_tld}": f""" + Alias /dav-ops "{dav_dir_conf}" + + Dav On + Require all granted + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def _dav_url(self, env, path=''): + return env.mkurl("http", "test1", f"/dav-ops/{path}") + + def _write_dav_file(self, env, name, content): + """Write a file directly into the DAV directory on disk.""" + dav_dir = os.path.join(env.gen_dir, 'dav-ops') + fpath = os.path.join(dav_dir, name) + with open(fpath, 'w') as fd: + fd.write(content) + + def _remove_dav_file(self, env, name): + """Remove a file from the DAV directory if it exists.""" + fpath = os.path.join(env.gen_dir, 'dav-ops', name) + if os.path.isfile(fpath): + os.remove(fpath) + + def _write_temp(self, env, name, content): + """Write a temporary file outside the DAV directory for uploads.""" + fpath = os.path.join(env.gen_dir, name) + with open(fpath, 'w') as fd: + fd.write(content) + return fpath + + @staticmethod + def _lock_body(): + return ('' + '' + '' + '' + 'test-owner' + '') + + # HEAD request on a DAV resource returns 200 + def test_dav_004_01_head_req(self, env): + self._write_dav_file(env, 'hello.txt', 'hello') + url = self._dav_url(env, 'hello.txt') + r = env.curl_raw(url, options=['-I']) + assert r.response["status"] == 200 + + # GET retrieves the correct file content + def test_dav_004_02_get_req(self, env): + self._write_dav_file(env, 'hello.txt', 'hello') + url = self._dav_url(env, 'hello.txt') + r = env.curl_raw(url) + assert r.response["status"] == 200 + assert r.stdout.strip() == 'hello' + + # PUT uploads content, subsequent GET returns the same content + def test_dav_004_03_put_req(self, env): + self._remove_dav_file(env, 'put_test.txt') + content = 'uploaded via put' + fpath = self._write_temp(env, 'put_content.txt', content) + url = self._dav_url(env, 'put_test.txt') + r = env.curl_raw(url, options=['-T', fpath]) + assert r.response["status"] == 201 + # verify with GET + r = env.curl_raw(url) + assert r.response["status"] == 200 + assert r.stdout.strip() == content + + # COPY duplicates a file; the copy has the original content + def test_dav_004_04_copy_req(self, env): + self._write_dav_file(env, 'hello.txt', 'hello') + self._remove_dav_file(env, 'hello_copy.txt') + src_url = self._dav_url(env, 'hello.txt') + dest_url = self._dav_url(env, 'hello_copy.txt') + r = env.curl_raw(src_url, options=[ + '-X', 'COPY', + '-H', f'Destination: {dest_url}']) + assert r.response["status"] == 201 + # verify the copy has the original content + r = env.curl_raw(dest_url) + assert r.response["status"] == 200 + assert r.stdout.strip() == 'hello' + + # MOVE transfers a file; the source returns 404 afterwards + def test_dav_004_05_move_req(self, env): + self._write_dav_file(env, 'move_src.txt', 'move me') + self._remove_dav_file(env, 'move_dest.txt') + src_url = self._dav_url(env, 'move_src.txt') + dest_url = self._dav_url(env, 'move_dest.txt') + r = env.curl_raw(src_url, options=[ + '-X', 'MOVE', + '-H', f'Destination: {dest_url}']) + assert r.response["status"] == 201 + # source should be gone + r = env.curl_raw(src_url) + assert r.response["status"] == 404 + # destination should have the content + r = env.curl_raw(dest_url) + assert r.response["status"] == 200 + assert r.stdout.strip() == 'move me' + + # DELETE removes a file; subsequent GET returns 404 + def test_dav_004_06_delete_req(self, env): + self._write_dav_file(env, 'delete_me.txt', 'delete this') + url = self._dav_url(env, 'delete_me.txt') + r = env.curl_raw(url, options=['-X', 'DELETE']) + assert r.response["status"] == 204 + # verify the file is gone + r = env.curl_raw(url) + assert r.response["status"] == 404 + + # PROPFIND with Depth:1 returns a listing that includes file names + def test_dav_004_07_prpfind_req(self, env): + self._write_dav_file(env, 'hello.txt', 'hello') + url = self._dav_url(env) + r = env.curl_raw(url, options=[ + '-X', 'PROPFIND', + '-H', 'Depth: 1']) + assert r.response["status"] == 207 + assert 'hello.txt' in r.stdout + + # MKCOL creates a collection; PROPFIND on the parent shows it + def test_dav_004_08_mkcol_req(self, env): + col_dir = os.path.join(env.gen_dir, 'dav-ops', 'newcol') + shutil.rmtree(col_dir, ignore_errors=True) + url = self._dav_url(env, 'newcol') + r = env.curl_raw(url, options=['-X', 'MKCOL']) + assert r.response["status"] == 201 + # verify the collection appears in the parent listing + parent_url = self._dav_url(env) + r = env.curl_raw(parent_url, options=[ + '-X', 'PROPFIND', + '-H', 'Depth: 1']) + assert r.response["status"] == 207 + assert 'newcol' in r.stdout + + # After LOCK, PUT/DELETE/MOVE fail with 423 Locked but GET still works + def test_dav_004_09_check_fail(self, env): + self._write_dav_file(env, 'lock_test.txt', 'lock me') + url = self._dav_url(env, 'lock_test.txt') + fpath = self._write_temp(env, 'lock_body.xml', self._lock_body()) + r = env.curl_raw(url, options=[ + '-X', 'LOCK', + '-H', 'Content-Type: text/xml', + '--data-binary', f'@{fpath}']) + assert r.response["status"] == 200 + # PUT should fail with 423 + put_fpath = self._write_temp(env, 'put_locked.txt', 'overwrite attempt') + r = env.curl_raw(url, options=['-T', put_fpath]) + assert r.response["status"] == 423 + # DELETE should fail with 423 + r = env.curl_raw(url, options=['-X', 'DELETE']) + assert r.response["status"] == 423 + # MOVE should fail with 423 + move_dest = self._dav_url(env, 'lock_moved.txt') + r = env.curl_raw(url, options=[ + '-X', 'MOVE', + '-H', f'Destination: {move_dest}']) + assert r.response["status"] == 423 + # GET should still succeed on a write-locked resource + r = env.curl_raw(url) + assert r.response["status"] == 200 + assert r.stdout.strip() == 'lock me' + env.httpd_error_log.ignore_recent( + matches=[r'.*dav:error.*']) + + # After LOCK then UNLOCK, DELETE succeeds + def test_dav_004_10_after_lock_unlock_delete(self, env): + self._write_dav_file(env, 'unlock_test.txt', 'unlock me') + url = self._dav_url(env, 'unlock_test.txt') + fpath = self._write_temp(env, 'unlock_body.xml', self._lock_body()) + r = env.curl_raw(url, options=[ + '-X', 'LOCK', + '-H', 'Content-Type: text/xml', + '--data-binary', f'@{fpath}']) + assert r.response["status"] == 200 + # extract the lock token from the response header + lock_token = r.response["header"]["lock-token"] + # unlock the resource + r = env.curl_raw(url, options=[ + '-X', 'UNLOCK', + '-H', f'Lock-Token: {lock_token}']) + assert r.response["status"] == 204 + # DELETE should now succeed + r = env.curl_raw(url, options=['-X', 'DELETE']) + assert r.response["status"] == 204 diff --git a/test/modules/dav/test_005_if_header.py b/test/modules/dav/test_005_if_header.py new file mode 100644 index 00000000000..298698b9d39 --- /dev/null +++ b/test/modules/dav/test_005_if_header.py @@ -0,0 +1,55 @@ +import os +import sys +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestDavIfHeader: + """mod_dav out-of-bounds read/write of zero byte when parsing a + malformed 'Not' token in the If: header. The fix should reject + the malformed header with 400 Bad Request.""" + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + dav_dir = os.path.join(env.gen_dir, 'dav-if') + os.makedirs(dav_dir, exist_ok=True) + with open(os.path.join(dav_dir, "hello.txt"), "w") as f: + f.write("hello") + dav_dir_conf = dav_dir.replace('\\', '/') + lock_db_conf = os.path.join(env.gen_dir, 'davlock-if').replace('\\', '/') + conf = HttpdConf(env, extras={ + 'base': f""" + DavLockDB "{lock_db_conf}" + """, + f"test1.{env.http_tld}": f""" + Alias /dav-if "{dav_dir_conf}" + + Dav On + Require all granted + + LogLevel debug + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_dav_005_01_copy_with_trucated_not(self, env): + """COPY with truncated 'Not' in If: header should return 400, + not crash or succeed.""" + url = env.mkurl("http", "test1", "/dav-if/hello.txt") + dest = env.mkurl("http", "test1", "/dav-if/hello2.txt") + r = env.curl_raw(url, options=[ + '-X', 'COPY', + '-H', f'Destination: {dest}', + '-H', 'Overwrite: T', + '-H', 'If: (N)', + '-v', + ]) + assert r.response is not None + assert r.response["status"] == 400, \ + f"Malformed If: header should produce 400, got {r.response['status']}" + env.httpd_error_log.ignore_recent( + matches=[r'.*Invalid.*If.*header.*', r'.*dav:error.*', + r'.*:error\].*'])