Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 38 additions & 19 deletions graphify/extractors/sln.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from pathlib import Path
from graphify.extractors.base import _make_id

#: Visual Studio's well-known project type GUID for a solution folder -- a
#: grouping that exists only inside the .sln, with no counterpart on disk.
_SOLUTION_FOLDER_TYPE_GUID = "2150e333-8fdc-42a3-9474-1a3956d46de8"


def extract_sln(path: Path) -> dict:
"""Extract projects and inter-project dependencies from a .sln file."""
Expand All @@ -23,32 +27,47 @@ def extract_sln(path: Path) -> dict:
seen_ids.add(file_nid)

_PROJECT_RE = re.compile(
r'Project\("[^"]*"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"'
r'Project\("([^"]*)"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"'
)
_DEP_RE = re.compile(r'\{([0-9a-fA-F-]+)\}\s*=\s*\{([0-9a-fA-F-]+)\}')

guid_to_nid: dict[str, str] = {}

for m in _PROJECT_RE.finditer(src):
proj_name = m.group(1)
proj_path = m.group(2).replace("\\", "/")
proj_guid = m.group(3).strip("{}")
proj_type = m.group(1).strip("{}").lower()
proj_name = m.group(2)
proj_path = m.group(3).replace("\\", "/")
proj_guid = m.group(4).strip("{}")

# A solution folder is a VIRTUAL grouping declared only inside the .sln --
# there is no such directory on disk -- so it is not a file and does not
# become a node. Visual Studio marks one with this project type GUID, and
# writes the folder name in the path position, e.g.
#
# Project("{2150E333-...}") = "Solution Items", "Solution Items", "{C40B...}"
#
# against a real project's relative path:
#
# Project("{8BC9CEB8-...}") = "jpgfltr", "jpgfltr\jpgfltr.vcproj", "{EA73...}"
#
# Emitting it produced a node whose source_file was a bare name with no
# directory component and a null source_location, which reads downstream as
# a stray unignored top-level path -- and no .graphifyignore pattern can
# match it, because there is no path to match.
#
# This supersedes the earlier `proj_path == proj_name` heuristic added for
# #1789 (resolving a folder to an absolute path leaked the scan path, and
# the OS username with it, into graph.json). Skipping the entry entirely
# closes that leak too, and keys off the field that actually states what
# the entry is rather than a coincidence between two other fields -- a real
# project whose path equals its name would have tripped the old test.
if proj_type == _SOLUTION_FOLDER_TYPE_GUID:
continue

# A solution folder is a VIRTUAL grouping, not a file: Visual Studio writes
# its name as both the display name and the "path" (proj_name == proj_path,
# no real file). Resolving it to an absolute path and keying the node id off
# that leaked the absolute scan path (incl. the OS username) into graph.json,
# because the CLI's id-relativization only remaps ids of real files in the
# scan set — a virtual folder never matches, so its absolute id survived
# (#1789). Use the folder name itself (relative, no filesystem resolution).
is_solution_folder = proj_path == proj_name
if is_solution_folder:
abs_proj = proj_name
else:
try:
abs_proj = str((path.parent / proj_path).resolve())
except Exception:
abs_proj = proj_path
try:
abs_proj = str((path.parent / proj_path).resolve())
except Exception:
abs_proj = proj_path
proj_nid = _make_id(abs_proj)
if proj_nid and proj_nid not in seen_ids:
seen_ids.add(proj_nid)
Expand Down
51 changes: 37 additions & 14 deletions tests/test_dotnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,56 @@ def test_sln_project_dependency():
assert "imports" in _relations(r)


def test_sln_solution_folder_ids_are_relative(tmp_path):
"""Solution folders are virtual groupings, not files. Their node ids must be
derived from the folder name only — never the resolved absolute scan path,
which would leak the local username into a committed graph.json (#1789)."""
def test_sln_solution_folder_is_not_a_node(tmp_path):
"""A solution folder is a virtual grouping declared only inside the .sln, so
it is not a file and does not become a node.

Supersedes the previous behaviour, which emitted the folder with an id derived
from its name so that the resolved absolute scan path (and the local username
with it) could not leak into a committed graph.json (#1789). Not emitting the
entry at all satisfies that more directly, and keys off the project type GUID
-- the field that states what the entry is -- rather than the coincidence that
a folder repeats its name in the path position.
"""
sln = tmp_path / "App.sln"
sln.write_text(
'Microsoft Visual Studio Solution File, Format Version 12.00\n'
# a solution folder: type GUID 2150E333-... , name == path, no real file
# a solution folder: type GUID 2150E333-..., name repeated as the path
'Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", '
'"{11111111-1111-1111-1111-111111111111}"\n'
'EndProject\n'
# a real project resolves to an absolute path as before
# a real project still resolves to its path as before
'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "App\\App.csproj", '
'"{22222222-2222-2222-2222-222222222222}"\n'
'EndProject\n',
encoding="utf-8",
)
r = extract_sln(sln)
assert "error" not in r
# The virtual solution folder must be keyed off its name, with no trace of the
# absolute scan path. (Real-file nodes — the .sln and .csproj — legitimately
# carry absolute ids here; the CLI's id-relativization pass remaps those, but
# never the virtual folder, which is why the leak had to be fixed at source.)
folder = next(n for n in r["nodes"] if n["label"] == "Plugins")
assert folder["id"] == "plugins"
assert folder["source_file"] == "Plugins"
assert str(tmp_path) not in folder["id"]
labels = {n["label"] for n in r["nodes"]}
assert "Plugins" not in labels
assert "App" in labels
# nothing anywhere in the result may carry the absolute scan path (#1789)
assert all(str(tmp_path) not in n["id"] for n in r["nodes"] if n["label"] == "Plugins")


def test_sln_project_named_like_its_path_is_still_a_node(tmp_path):
"""The old folder test was `path == name`, which a real project can satisfy.

A project whose relative path happens to equal its display name is a file and
must still be extracted; only the type GUID decides.
"""
sln = tmp_path / "App.sln"
sln.write_text(
'Microsoft Visual Studio Solution File, Format Version 12.00\n'
'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools", "Tools", '
'"{33333333-3333-3333-3333-333333333333}"\n'
'EndProject\n',
encoding="utf-8",
)
r = extract_sln(sln)
assert "error" not in r
assert "Tools" in {n["label"] for n in r["nodes"]}


# ── .slnx ────────────────────────────────────────────────────────────────────
Expand Down
Loading