All of lore.kernel.org
 help / color / mirror / Atom feed
From: Trevor Woerner <twoerner@gmail.com>
To: yocto-patches@lists.yoctoproject.org
Subject: [wic][PATCH v2 4/4] tests/unit/test_oe_path: cover oe/path's own path logic
Date: Fri, 24 Jul 2026 07:50:20 -0400	[thread overview]
Message-ID: <20260724115020.38079-5-twoerner@gmail.com> (raw)
In-Reply-To: <20260724115020.38079-1-twoerner@gmail.com>

Add unit coverage for the wic-specific behaviour in oe/path.py:

  - join() keeps an absolute right-hand component relative and
    normalises redundant separators, '.', and '..'.
  - is_path_parent() treats containment by path component, so a shared
    string prefix such as /usrlocal is not below /usr, and every
    supplied path must be below the parent.
  - symlink() creates links, is idempotent for a matching link, raises
    on a conflicting one, and with force=True replaces the literal
    destination -- including names with glob metacharacters and an
    existing directory -- without deleting glob siblings. A dangling
    link whose source does not exist yet is also covered.
  - make_relative_symlink() leaves non-links and already-relative links
    alone, rewrites an absolute link to a working relative one, and
    prepends multiple '../' segments when the link sits several
    directories below its target.
  - canonicalize() expands real paths, drops '$' and empty tokens
    (including a run of '$' tokens), keeps trailing slashes, and returns
    '' for '' and None.
  - which_wild() honours an explicit search path, expands wildcards, and
    returns the first match per name (last with reverse=True).
  - realpath() resolves links below root (including several components
    deep under use_physdir), rejects paths outside root, raises ELOOP on
    a self-referential link and on an A->B->A cycle, honours the
    assume_dir contract (ENOENT for a missing component by default,
    tolerated when assume_dir=True), and falls back cleanly when
    os.path.isdir() raises.

AI-Generated: codex/claude-opus 4.8 (xhigh)
Signed-off-by: Trevor Woerner <twoerner@gmail.com>
---
changes in v2:
- Expand test_oe_path coverage: realpath() ELOOP on a self-loop and on
  an A->B->A cycle, multi-component resolution under use_physdir, and
  the assume_dir ENOENT-vs-tolerated contract; a dangling symlink()
  whose source does not exist yet; make_relative_symlink() several
  directories below its target (multi-level '../'); and two
  canonicalize() cases (a run of '$' tokens all dropped, and a dropped
  '$' token before a trailing-slash path). Module goes 42 -> 51 test
  functions.
- Rebased onto current master (past the merged ksparser test series); no
  change to the fixes themselves.
---
 tests/unit/test_oe_path.py | 378 +++++++++++++++++++++++++++++++++++++
 1 file changed, 378 insertions(+)
 create mode 100644 tests/unit/test_oe_path.py

diff --git a/tests/unit/test_oe_path.py b/tests/unit/test_oe_path.py
new file mode 100644
index 000000000000..3abe1f0873a3
--- /dev/null
+++ b/tests/unit/test_oe_path.py
@@ -0,0 +1,378 @@
+"""
+Unit tests for wic/oe/path.py, covering wic's own path logic: join,
+is_path_parent, symlink, make_relative_symlink, canonicalize, which_wild,
+and realpath. Thin standard-library wrappers (relative -> os.path.relpath,
+find -> os.walk) are left to the standard library.
+"""
+import errno
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+_SRC = Path(__file__).resolve().parent.parent.parent / "src"
+if str(_SRC) not in sys.path:
+    sys.path.insert(0, str(_SRC))
+
+from wic.oe.path import (
+    join,
+    is_path_parent,
+    symlink,
+    make_relative_symlink,
+    canonicalize,
+    which_wild,
+    realpath,
+)
+
+
+class TestJoin:
+    """join() is os.path.normpath("/".join(paths)); unlike os.path.join it
+    does not treat an absolute right-hand component specially."""
+
+    def test_two_paths(self):
+        assert join("a", "b") == "a/b"
+
+    def test_three_paths(self):
+        assert join("a", "b", "c") == "a/b/c"
+
+    def test_absolute_rhs_is_not_special(self):
+        # os.path.join("a", "/b") == "/b"; join() keeps it relative.
+        assert join("a", "/b") == "a/b"
+
+    def test_redundant_separators_normalised(self):
+        assert join("a//", "b") == "a/b"
+
+    def test_pardir_normalised(self):
+        assert join("a", "..", "b") == "b"
+
+    def test_curdir_normalised(self):
+        assert join("a", ".", "b") == "a/b"
+
+    def test_single_component(self):
+        assert join("a") == "a"
+
+    def test_empty_leading_component_is_absolute(self):
+        # "/".join(["", "b"]) is "/b", which normpath leaves absolute.
+        assert join("", "b") == "/b"
+
+
+class TestIsPathParent:
+    def test_direct_child(self):
+        assert is_path_parent("/usr", "/usr/bin") is True
+
+    def test_deep_child(self):
+        assert is_path_parent("/usr", "/usr/share/doc/readme") is True
+
+    def test_unrelated_path(self):
+        assert is_path_parent("/usr", "/tmp") is False
+
+    def test_child_is_not_parent_of_its_parent(self):
+        assert is_path_parent("/usr/bin", "/usr") is False
+
+    def test_prefix_string_is_not_a_path_parent(self):
+        # "/usrlocal" shares a string prefix with "/usr" but is not below it;
+        # the trailing-separator handling must reject it.
+        assert is_path_parent("/usr", "/usrlocal") is False
+
+    def test_no_paths_returns_false(self):
+        assert is_path_parent("/usr") is False
+
+    def test_all_paths_must_be_below(self):
+        assert is_path_parent("/usr", "/usr/bin", "/usr/lib") is True
+
+    def test_one_path_outside_fails(self):
+        assert is_path_parent("/usr", "/usr/bin", "/tmp") is False
+
+
+class TestSymlink:
+    def test_creates_symlink(self, tmp_path):
+        src = tmp_path / "target.txt"
+        src.write_text("content")
+        dst = tmp_path / "link"
+        symlink(str(src), str(dst))
+        assert os.path.islink(str(dst))
+        assert os.readlink(str(dst)) == str(src)
+
+    def test_matching_existing_symlink_is_idempotent(self, tmp_path):
+        src = tmp_path / "target.txt"
+        src.write_text("content")
+        dst = tmp_path / "link"
+        symlink(str(src), str(dst))
+        symlink(str(src), str(dst))
+        assert os.readlink(str(dst)) == str(src)
+
+    def test_conflicting_existing_symlink_raises(self, tmp_path):
+        src1 = tmp_path / "t1.txt"
+        src2 = tmp_path / "t2.txt"
+        src1.write_text("a")
+        src2.write_text("b")
+        dst = tmp_path / "link"
+        symlink(str(src1), str(dst))
+        with pytest.raises(OSError):
+            symlink(str(src2), str(dst))
+
+    def test_force_overwrites_existing_symlink(self, tmp_path):
+        src1 = tmp_path / "t1"
+        src2 = tmp_path / "t2"
+        src1.write_text("a")
+        src2.write_text("b")
+        dst = tmp_path / "link"
+        symlink(str(src1), str(dst))
+        symlink(str(src2), str(dst), force=True)
+        assert os.readlink(str(dst)) == str(src2)
+
+    def test_force_replaces_destination_with_glob_metacharacters(self, tmp_path):
+        # A destination whose name contains glob metacharacters must still be
+        # replaced. Routing the removal through glob (the old behaviour) fails
+        # to match "link[1]" against itself, leaving the stale entry in place.
+        src = tmp_path / "target"
+        src.write_text("content")
+        dst = tmp_path / "link[1]"
+        dst.write_text("stale regular file")
+        symlink(str(src), str(dst), force=True)
+        assert os.path.islink(str(dst))
+        assert os.readlink(str(dst)) == str(src)
+
+    def test_force_does_not_delete_glob_siblings(self, tmp_path):
+        # The destination "keep?.txt" does not exist, but a sibling
+        # "keepX.txt" matches it as a glob pattern. force=True must remove
+        # only the literal destination, never a pattern sibling.
+        sibling = tmp_path / "keepX.txt"
+        sibling.write_text("do not delete me")
+        src = tmp_path / "target"
+        src.write_text("content")
+        dst = tmp_path / "keep?.txt"
+        symlink(str(src), str(dst), force=True)
+        assert sibling.exists()
+        assert os.readlink(str(dst)) == str(src)
+
+    def test_force_replaces_existing_directory(self, tmp_path):
+        # An existing directory at the destination is torn down (EISDIR ->
+        # rmtree) before the link is created.
+        src = tmp_path / "target"
+        src.write_text("content")
+        dst = tmp_path / "dir"
+        dst.mkdir()
+        (dst / "child").write_text("x")
+        symlink(str(src), str(dst), force=True)
+        assert os.path.islink(str(dst))
+        assert os.readlink(str(dst)) == str(src)
+
+    def test_creates_dangling_symlink(self, tmp_path):
+        # symlink() does not require the source to exist; wic relies on being
+        # able to stage a link before its target is populated.
+        src = tmp_path / "not_created_yet"
+        dst = tmp_path / "link"
+        symlink(str(src), str(dst))
+        assert os.path.islink(str(dst))
+        assert os.readlink(str(dst)) == str(src)
+        assert not os.path.exists(str(dst))  # dangling: target is absent
+
+
+class TestMakeRelativeSymlink:
+    def test_non_symlink_is_ignored(self, tmp_path):
+        regular = tmp_path / "file.txt"
+        regular.write_text("content")
+        make_relative_symlink(str(regular))
+        assert regular.is_file()
+
+    def test_already_relative_symlink_is_unchanged(self, tmp_path):
+        target = tmp_path / "target.txt"
+        target.write_text("content")
+        link = tmp_path / "link"
+        os.symlink("target.txt", str(link))
+        make_relative_symlink(str(link))
+        assert os.readlink(str(link)) == "target.txt"
+
+    def test_absolute_symlink_becomes_relative_and_resolves(self, tmp_path):
+        target = tmp_path / "target.txt"
+        target.write_text("content")
+        link = tmp_path / "link"
+        os.symlink(str(target), str(link))
+        assert os.path.isabs(os.readlink(str(link)))
+        make_relative_symlink(str(link))
+        result = os.readlink(str(link))
+        assert not os.path.isabs(result)
+        assert os.path.exists(str(link))
+        assert Path(str(link)).read_text() == "content"
+
+    def test_absolute_symlink_several_levels_deep_becomes_relative(self, tmp_path):
+        # The link sits several directories below the target, so the
+        # depth loop has to prepend more than one '../' segment. A
+        # single-level case never exercises that loop body.
+        target = tmp_path / "target.txt"
+        target.write_text("content")
+        deep = tmp_path / "a" / "b" / "c"
+        deep.mkdir(parents=True)
+        link = deep / "link"
+        os.symlink(str(target), str(link))
+        make_relative_symlink(str(link))
+        result = os.readlink(str(link))
+        assert not os.path.isabs(result)
+        assert result == "../../../target.txt"
+        assert Path(str(link)).read_text() == "content"
+
+
+class TestCanonicalize:
+    def test_real_path_returned(self, tmp_path):
+        result = canonicalize(str(tmp_path))
+        assert result == os.path.realpath(str(tmp_path))
+
+    def test_multiple_variable_tokens_all_skipped(self, tmp_path):
+        # Every "$"-bearing token is dropped, so a list made only of them
+        # canonicalizes to the empty string (no separators, no cwd).
+        assert canonicalize("$A,$B,$C") == ""
+
+    def test_variable_and_trailing_slash_path(self, tmp_path):
+        # A dropped "$" token followed by a real path with a trailing slash:
+        # the slash is preserved and no phantom leading separator appears.
+        result = canonicalize("$VAR,%s/" % tmp_path)
+        assert result == os.path.realpath(str(tmp_path)) + "/"
+
+    def test_unexpanded_variable_is_skipped(self):
+        assert canonicalize("$SOME_VAR") == ""
+
+    def test_variable_token_dropped_leaving_real_path(self, tmp_path):
+        result = canonicalize("$VAR," + str(tmp_path))
+        # The "$VAR" token is dropped entirely: no empty placeholder, no
+        # leading separator, just the canonical real path.
+        assert result == os.path.realpath(str(tmp_path))
+
+    def test_empty_string_returns_empty_string(self):
+        # os.path.realpath("") is the cwd; canonicalize("") must not leak it.
+        assert canonicalize("") == ""
+
+    def test_none_returns_empty_string(self):
+        assert canonicalize(None) == ""
+
+    def test_empty_token_between_paths_is_dropped(self, tmp_path):
+        p1 = tmp_path / "a"
+        p2 = tmp_path / "b"
+        p1.mkdir()
+        p2.mkdir()
+        # The stray separator in "a,,b" must not inject a cwd entry.
+        result = canonicalize("%s,,%s" % (p1, p2))
+        assert result == "%s,%s" % (
+            os.path.realpath(str(p1)), os.path.realpath(str(p2)))
+
+    def test_trailing_slash_preserved(self, tmp_path):
+        result = canonicalize(str(tmp_path) + "/")
+        assert result == os.path.realpath(str(tmp_path)) + "/"
+
+
+class TestWhichWild:
+    def test_finds_existing_executable(self):
+        results = which_wild("python3")
+        assert results
+        assert all(os.path.isabs(p) for p in results)
+        assert all(os.path.basename(p) == "python3" for p in results)
+
+    def test_missing_tool_returns_empty(self):
+        assert which_wild("totally_nonexistent_tool_xyz") == []
+
+    def test_explicit_search_path(self, tmp_path):
+        tool = tmp_path / "mytool"
+        tool.write_text("#!/bin/sh\n")
+        tool.chmod(0o755)
+        results = which_wild("mytool", path=str(tmp_path))
+        assert results == [str(tool)]
+
+    def test_wildcard_pattern(self, tmp_path):
+        (tmp_path / "foo-a").write_text("")
+        (tmp_path / "foo-b").write_text("")
+        (tmp_path / "bar").write_text("")
+        results = which_wild("foo-*", path=str(tmp_path))
+        assert sorted(os.path.basename(p) for p in results) == ["foo-a", "foo-b"]
+
+    def test_first_match_per_name_wins(self, tmp_path):
+        # A name found in an earlier PATH element shadows the same name later.
+        first = tmp_path / "first"
+        second = tmp_path / "second"
+        first.mkdir()
+        second.mkdir()
+        (first / "tool").write_text("")
+        (second / "tool").write_text("")
+        search = "%s:%s" % (first, second)
+        assert which_wild("tool", path=search) == [str(first / "tool")]
+        # reverse=True walks PATH the other way, so the other copy wins.
+        assert which_wild("tool", path=search, reverse=True) == [str(second / "tool")]
+
+
+class TestRealpath:
+    def test_resolves_symlink_below_root(self, tmp_path):
+        target = tmp_path / "real"
+        target.mkdir()
+        link = tmp_path / "lnk"
+        os.symlink("real", str(link))
+        result = realpath(str(link), str(tmp_path))
+        assert result == str(target)
+
+    def test_plain_path_is_returned(self, tmp_path):
+        sub = tmp_path / "d"
+        sub.mkdir()
+        assert realpath(str(sub), str(tmp_path)) == str(sub)
+
+    def test_path_outside_root_raises(self, tmp_path):
+        root = tmp_path / "root"
+        outside = tmp_path / "outside"
+        root.mkdir()
+        outside.mkdir()
+        with pytest.raises(OSError):
+            realpath(str(outside), str(root))
+
+    def test_resolves_symlink_several_components_deep(self, tmp_path):
+        # With use_physdir=True realpath resolves each intermediate
+        # component, so a link buried under real subdirectories must still
+        # resolve to its final target.
+        (tmp_path / "a" / "b" / "c").mkdir(parents=True)
+        link = tmp_path / "link_to_c"
+        os.symlink("a/b/c", str(link))
+        result = realpath(str(link / "file"), str(tmp_path), assume_dir=True)
+        assert result == str(tmp_path / "a" / "b" / "c" / "file")
+
+    def test_self_referential_symlink_raises_eloop(self, tmp_path):
+        # A link that points at itself must be reported as ELOOP rather than
+        # spinning until the recursion limit or the stack blows.
+        link = tmp_path / "loop"
+        os.symlink("loop", str(link))
+        with pytest.raises(OSError) as exc:
+            realpath(str(link), str(tmp_path))
+        assert exc.value.errno == errno.ELOOP
+
+    def test_mutual_symlink_cycle_raises_eloop(self, tmp_path):
+        # An A -> B -> A cycle is the same contract as the self-loop.
+        a = tmp_path / "a"
+        b = tmp_path / "b"
+        os.symlink("b", str(a))
+        os.symlink("a", str(b))
+        with pytest.raises(OSError) as exc:
+            realpath(str(a), str(tmp_path))
+        assert exc.value.errno == errno.ELOOP
+
+    def test_missing_component_raises_enoent_without_assume_dir(self, tmp_path):
+        # By default a missing path component is an error.
+        with pytest.raises(OSError) as exc:
+            realpath(str(tmp_path / "nope" / "missing"), str(tmp_path))
+        assert exc.value.errno == errno.ENOENT
+
+    def test_missing_component_tolerated_with_assume_dir(self, tmp_path):
+        # assume_dir=True lets realpath resolve a path whose trailing
+        # components do not exist yet, returning the composed path.
+        result = realpath(str(tmp_path / "nope" / "missing"),
+                          str(tmp_path), assume_dir=True)
+        assert result == str(tmp_path / "nope" / "missing")
+
+    def test_isdir_failure_falls_back_without_nameerror(self, tmp_path, monkeypatch):
+        # The final stanza of __realpath tolerates any failure from
+        # os.path.isdir by treating the path as not-a-directory. Force isdir
+        # to raise and confirm the fallback returns the resolved path rather
+        # than raising NameError from an undefined fallback value.
+        sub = tmp_path / "d"
+        sub.mkdir()
+
+        def boom(_path):
+            raise OSError("synthetic stat failure")
+
+        monkeypatch.setattr(os.path, "isdir", boom)
+        assert realpath(str(sub), str(tmp_path), use_physdir=False) == str(sub)
-- 
2.50.0.173.g8b6f19ccfc3a



  parent reply	other threads:[~2026-07-24 11:50 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-24 11:50 [wic][PATCH v2 0/4] oe/path: three fixes plus unit coverage Trevor Woerner
2026-07-24 11:50 ` [wic][PATCH v2 1/4] oe/path: fix bare `false` NameError in __realpath's isdir guard Trevor Woerner
2026-07-24 11:50 ` [wic][PATCH v2 2/4] oe/path: don't glob-expand the destination in symlink(force=True) Trevor Woerner
2026-07-24 11:50 ` [wic][PATCH v2 3/4] oe/path: canonicalize('') should return '' rather than the cwd Trevor Woerner
2026-07-24 11:50 ` Trevor Woerner [this message]
2026-07-24 15:04 ` [yocto-patches] [wic][PATCH v2 0/4] oe/path: three fixes plus unit coverage Paul Barker

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260724115020.38079-5-twoerner@gmail.com \
    --to=twoerner@gmail.com \
    --cc=yocto-patches@lists.yoctoproject.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.