diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-08 01:11:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-08 01:11:38 +0200 |
| commit | ed0c680790950354f15fb5835e1d7b213efa1bf8 (patch) | |
| tree | 59af376652005a0b02c95945c1df6044882a94e5 | |
| parent | 4c8e4fb8b8fcd3f78bf4e3e736a058c351aac973 (diff) | |
| download | meshbay-ed0c680790950354f15fb5835e1d7b213efa1bf8.tar.gz | |
fix(node): name a root after its drive when its basename is taken
Two drives with a folder of the same name made the second add fail,
and no screen could supply another name. add_root now names it
"Name (H)" or "Name (parent)"; a name the operator typed is still
refused on a clash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 9 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops/roots.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roots.py | 21 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roots.py | 16 |
6 files changed, 90 insertions, 8 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 9decd34..23ced38 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1503,9 +1503,12 @@ stored.** Never recomputed from the path: renaming a folder on disk would otherwise silently re-identify a whole library and break every stored reference to it. Four rules make basename naming safe: -- **A duplicate basename is refused, case-insensitively.** Collisions are common in - practice (`D:\Films` and `E:\Films`). Refusing is correct; an explicit alias is - the escape hatch (open item O11). +- **A duplicate name is refused, case-insensitively.** Collisions are common in + practice (`D:\Films` and `E:\Films`), and the interface has no field for a name, + so `add_root` gives a folder whose basename is taken a name saying where it is: + `Films (E)`, or `Films (b)` for `/mnt/b/Films` (`roots.distinct_name`). Only a + name the operator typed is refused on a clash. A drive root, which has no + basename at all, still needs an explicit alias (open item O11). - **No root may contain another**, compared case-insensitively and after canonicalisation. Two nested roots would index the same bytes twice under two identities. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index ea81c76..adca9e3 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -504,8 +504,10 @@ meshbay-node root remove Films Rules that will bite you if you do not know them: -- **Two roots cannot share a basename**, even on different drives. `/mnt/a/Films` - and `/mnt/b/Films` is refused; name one of them explicitly. +- **Two roots cannot share a name**, even on different drives. When you add + `/mnt/b/Films` beside `/mnt/a/Films`, the second one is called `Films (b)` + (on Windows, `H:\Films` beside `G:\Films` becomes `Films (H)`). A name you + give with `--name` that is already taken is refused. - **No root inside another.** The same bytes would be indexed twice under two identities. - **Renaming a root rewrites every path under it**, so it is an explicit act, diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py index b976e8f..c272978 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/roots.py +++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py @@ -14,7 +14,7 @@ from meshbay_node.ops.node_toml import ( _update_root_field, toml_string, ) -from meshbay_node.roots import RootError, RootSet, off_disk +from meshbay_node.roots import RootError, RootSet, distinct_name, off_disk log = logging.getLogger("meshbay_node.ops") @@ -36,12 +36,21 @@ async def add_root(state: dict, group_id: str, path: str, *, raise OpError("Group not configured on this node", status=404) specs = [asdict(r) for r in cfg.roots] - specs.append({"path": path, "name": name, "kind": kind, - "writable": writable, "removable": removable}) try: + # Every caller sends the folder's basename when nobody typed a name, so + # that is the case that gets a distinct one. A name somebody chose is + # still refused on a clash. + target = Path(path).expanduser().resolve() + if name.strip() in ("", target.name): + taken = {r.folded for r in RootSet.build(specs).roots} + name = distinct_name(target, taken) + specs.append({"path": path, "name": name, "kind": kind, + "writable": writable, "removable": removable}) built = RootSet.build(specs) except RootError as e: raise OpError(str(e)) from e + except OSError as e: + raise OpError(f"{path}: {e}") from e added = built.roots[-1] diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index 2b48292..7dded1f 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -211,6 +211,27 @@ def derive_name(path: Path) -> str: return name +def distinct_name(path: Path, taken: set[str]) -> str: + """ + `derive_name(path)`, or, when a root in `taken` (folded names) already has + it, that name followed by where the directory is: `Archives (H)` for + `H:\\Archives` beside `G:\\Archives`, `Films (b)` for `/mnt/b/Films`. + + The same folder name on two drives is the ordinary case, and the interface + has no field to type a name into, so refusing it left no way forward. + """ + base = derive_name(path) + candidates = [base] + where = path.parent.name or path.drive.rstrip(":") + if where: + candidates.append(f"{base} ({where})") + candidates += [f"{base} ({n})" for n in range(2, 100)] + for name in candidates: + if fold(name) not in taken and not portable_name_problem(name): + return name + return base + + @dataclass class RootSet: """ diff --git a/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py index b2b3650..1a0d9f1 100644 --- a/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py +++ b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py @@ -235,6 +235,37 @@ async def test_adding_the_same_directory_twice_is_still_refused(tmp_path): await roster.close() +@pytest.mark.parametrize("sent_name", ["", "Archives"]) +async def test_the_same_folder_name_on_two_drives_gets_a_name_of_its_own(tmp_path, sent_name): + """The picker sends the basename; the second folder must not be refused for it.""" + state, roster = await _state(tmp_path) + for drive in ("g", "h"): + (tmp_path / drive / "Archives").mkdir(parents=True) + try: + first = await ops.add_root(state, GROUP, str(tmp_path / "g" / "Archives"), + name=sent_name) + second = await ops.add_root(state, GROUP, str(tmp_path / "h" / "Archives"), + name=sent_name) + assert first["name"] == "Archives" + assert second["name"] == "Archives (h)" + assert {"Archives", "Archives (h)"} <= set(_rebuilt(state).names), ( + "the name given to the second folder did not reach node.toml") + assert 'name = "Archives (h)"' in Path(state["config_path"]).read_text( + encoding="utf-8") + finally: + await roster.close() + + +async def test_a_name_somebody_chose_is_still_refused_on_a_clash(tmp_path): + state, roster = await _state(tmp_path) + (tmp_path / "other").mkdir() + try: + with pytest.raises(ops.OpError, match="both be called"): + await ops.add_root(state, GROUP, str(tmp_path / "other"), name="one") + finally: + await roster.close() + + async def test_a_second_different_root_still_lands(tmp_path): state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index 9a403ff..0fe9f03 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -8,13 +8,17 @@ copy breaks the other". """ +from pathlib import PurePosixPath, PureWindowsPath + import pytest +from meshbay_common.paths import fold from meshbay_common.protocol import IndexEntry from meshbay_node.roots import ( SAFE_UPLOAD_NAME, RootError, RootSet, _free_name, + distinct_name, entry_abs_path, safe_subdir, ) @@ -51,6 +55,18 @@ def test_two_roots_cannot_share_a_name(tmp_path): _spec(tmp_path / "b" / "Films")]) +def test_a_distinct_name_says_where_the_folder_is(): + taken = {fold("Archives")} + assert distinct_name(PureWindowsPath(r"H:\Archives"), taken) == "Archives (H)" + assert distinct_name(PurePosixPath("/mnt/b/Films"), {fold("Films")}) == "Films (b)" + assert distinct_name(PurePosixPath("/mnt/b/Films"), set()) == "Films" + + +def test_a_distinct_name_counts_when_where_is_taken_too(): + taken = {fold("Films"), fold("Films (b)")} + assert distinct_name(PurePosixPath("/mnt/b/Films"), taken) == "Films (2)" + + def test_names_clash_without_regard_to_case(tmp_path): """ `Films` and `films` are one directory on NTFS and exFAT, which is where most |