From 888460613815d01fe8e2a3e3b26b9c418a4ea6c2 Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 9 Jul 2026 21:42:59 +0000 Subject: [PATCH] shadowing: `//NULL` as unmap keyword --- README.md | 14 ++++++++++++++ copyparty/authsrv.py | 20 ++++++++++++++------ tests/res/idp/1.conf | 6 ++++++ tests/res/idp/2.conf | 6 ++++++ tests/test_idp.py | 14 ++++++++++++-- tests/test_vfs.py | 14 ++++++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9ae91b68..7e768f63 100644 --- a/README.md +++ b/README.md @@ -616,12 +616,26 @@ hiding specific subfolders by mounting another volume on top of them for example `-v /mnt::r -v /var/empty:web/certs:` (note: no permissions) mounts the server folder `/mnt` as the webroot, but another volume is mounted at `/web/certs` -- so visitors can only see the contents of `/mnt` and `/mnt/web` (at URLs `/` and `/web`), but not `/mnt/web/certs` because URL `/web/certs` is mapped to `/var/empty` +to fully unmap it from the filesystem, specify `//NULL` instead of a real path such as `/var/empty`, so for example `-v /mnt::r -v //NULL:web/certs:` ensures `/web/certs` will never be accessible by anyone + the example config file right above this section may explain this better; the first volume `/` is mapped to `/srv` which means http://127.0.0.1:3923/music would try to read `/srv/music` on the server filesystem, but since there's another volume at `/music` mapped to `/mnt/music` then it'll go to `/mnt/music` instead so, to shadow a file/folder, define a volume but leave out the `accs:` section > ℹ️ this also works for single files, because files can also be volumes +config file example for unmapping folders by shadowing: + +```yaml +[/drives] + /mnt # url "/drives" goes to "/mnt" + accs: + r: * # everyone can read + +[/drives/foo/bar] + //NULL # blocks access to "/mnt/foo/bar" +``` + ## dotfiles diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 016b49b7..9fa9b7d8 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -1195,7 +1195,7 @@ class AuthSrv(object): for ptn, sigil in ((PTN_U_ANY, "${u}"), (PTN_G_ANY, "${g}")): if bool(ptn.search(src)) != bool(ptn.search(dst)): zsl.append(sigil) - if zsl: + if zsl and src != "//NULL": t = "ERROR: if %s is mentioned in a volume definition, it must be included in both the filesystem-path [%s] and the volume-url [/%s]" t = "\n".join([t % (x, src, dst) for x in zsl]) self.log(t, 1) @@ -1275,8 +1275,16 @@ class AuthSrv(object): daxs: dict[str, AXS], mflags: dict[str, dict[str, Any]], ) -> tuple[str, str]: - src = os.path.expanduser(self.args.shenvexp(src)) - src = absreal(src) + if src == "//NULL": + src = "" + else: + src = os.path.expanduser(self.args.shenvexp(src)) + src = absreal(src) + + if dst == "//NULL": + t = "//NULL given as URL instead of filesystem-path" + self.log(t, 1) + raise Exception(t) dst = dst.strip("/") if dst in mount: @@ -1284,7 +1292,7 @@ class AuthSrv(object): self.log(t.format(dst, mount[dst][0], src), c=1) raise Exception(BAD_CFG) - if src in mount.values(): + if src and src in mount.values(): t = "filesystem-path [{}] mounted in multiple locations:" t = t.format(src) for v in [k for k, v in mount.items() if v[0] == src] + [dst]: @@ -1293,7 +1301,7 @@ class AuthSrv(object): self.log(t, c=3) raise Exception(BAD_CFG) - if not bos.path.exists(src): + if src and not bos.path.exists(src): self.log("warning: filesystem-path did not exist: %r" % (src,), 3) vf = {} @@ -1861,7 +1869,7 @@ class AuthSrv(object): if WINDOWS: cased = {} for vp, (ap, vp0) in mount.items(): - cased[vp] = (absreal(ap), vp0) + cased[vp] = (absreal(ap) if ap else "", vp0) mount = cased diff --git a/tests/res/idp/1.conf b/tests/res/idp/1.conf index 00a4916a..3492277f 100644 --- a/tests/res/idp/1.conf +++ b/tests/res/idp/1.conf @@ -15,3 +15,9 @@ [/vb] /b + +[/x1] + //NULL + +[/x2] + //NULL diff --git a/tests/res/idp/2.conf b/tests/res/idp/2.conf index 460c0019..c1bbef91 100644 --- a/tests/res/idp/2.conf +++ b/tests/res/idp/2.conf @@ -27,3 +27,9 @@ /c accs: r: @ga, uc + +[/x1] + //NULL + +[/x2] + //NULL diff --git a/tests/test_idp.py b/tests/test_idp.py index c1dce45c..c08fc54b 100644 --- a/tests/test_idp.py +++ b/tests/test_idp.py @@ -89,12 +89,17 @@ class TestVFS(unittest.TestCase): self.assertEqual(au.vfs.vpath, "") self.assertApEq(au.vfs.realpath, "/") - self.assertNodes(au.vfs, ["vb"]) + self.assertNodes(au.vfs, ["vb", "x1", "x2"]) self.assertNodes(au.vfs.nodes["vb"], []) + self.assertJump(au.vfs.nodes["x1"], []) + self.assertJump(au.vfs.nodes["x2"], []) self.assertAxs(au.vfs.axs, [["ua"]]) self.assertAxs(au.vfs.nodes["vb"].axs, []) + self.assertEqual("", au.vfs.nodes["x1"].realpath) + self.assertEqual("", au.vfs.nodes["x2"].realpath) + def test_2(self): """ users ua/ub/uc, group ga (ua+ub) in basic combinations @@ -104,15 +109,20 @@ class TestVFS(unittest.TestCase): self.assertEqual(au.vfs.vpath, "") self.assertApEq(au.vfs.realpath, "/") - self.assertNodes(au.vfs, ["vb", "vc"]) + self.assertNodes(au.vfs, ["vb", "vc", "x1", "x2"]) self.assertNodes(au.vfs.nodes["vb"], []) self.assertNodes(au.vfs.nodes["vc"], []) + self.assertJump(au.vfs.nodes["x1"], []) + self.assertJump(au.vfs.nodes["x2"], []) self.assertAxs(au.vfs.axs, [["ua", "ub"]]) self.assertAxsAt(au, "vb", [["ua", "ub"]]) # same as: self.assertAxs(au.vfs.nodes["vb"].axs, [["ua", "ub"]]) self.assertAxs(au.vfs.nodes["vc"].axs, [["ua", "ub", "uc"]]) + self.assertEqual("", au.vfs.nodes["x1"].realpath) + self.assertEqual("", au.vfs.nodes["x2"].realpath) + def test_3(self): """ IdP-only; dynamically created volumes for users/groups diff --git a/tests/test_vfs.py b/tests/test_vfs.py index 281c407d..4b0a726b 100644 --- a/tests/test_vfs.py +++ b/tests/test_vfs.py @@ -248,6 +248,20 @@ class TestVFS(unittest.TestCase): self.assertEqual(r1, r2) self.assertEqual(list(v1), list(v2)) + # shadowing II + self.wipe_vfs(td) + vfs = AuthSrv(Cfg(v=[".::r", "//NULL:a/ab:", "//NULL:a/ac:"]), self.log).vfs + + fsp, r1, v1 = self.ls(vfs, "", "*") + self.assertEqual(fsp, td) + self.assertEqual(r1, ["b", "c"]) + self.assertEqual(list(v1), ["a"]) + + fsp, r1, v1 = self.ls(vfs, "a", "*") + self.assertEqual(fsp, os.path.join(td, "a")) + self.assertEqual(r1, ["aa"]) + self.assertEqual(list(v1), []) # ["ab", "ac"] + # config file parser cfg_path = os.path.join(self.td, "test.cfg") with open(cfg_path, "wb") as f: