fluidattacks_core_filesystem 15.0.0__tar.gz → 16.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. fluidattacks_core_filesystem-16.0.0/.envrc +1 -0
  2. fluidattacks_core_filesystem-16.0.0/.gitignore +16 -0
  3. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/PKG-INFO +1 -1
  4. fluidattacks_core_filesystem-16.0.0/fluidattacks_core/filesystem/cargo.py +110 -0
  5. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/defaults.py +6 -0
  6. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/partitioning.py +94 -9
  7. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/import-linter.cfg +1 -1
  8. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/pyproject.toml +1 -1
  9. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/ruff.toml +3 -0
  10. fluidattacks_core_filesystem-16.0.0/tests/test_cargo.py +122 -0
  11. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_partitioning.py +140 -3
  12. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_projects.py +9 -0
  13. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/uv.lock +1 -1
  14. fluidattacks_core_filesystem-15.0.0/.envrc +0 -1
  15. fluidattacks_core_filesystem-15.0.0/.gitignore +0 -9
  16. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/__init__.py +0 -0
  17. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/detection.py +0 -0
  18. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/language.py +0 -0
  19. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/parts.py +0 -0
  20. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/projects.py +0 -0
  21. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/fluidattacks_core/filesystem/py.typed +0 -0
  22. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/mypy.ini +0 -0
  23. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/__init__.py +0 -0
  24. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/data/partitioning_snapshot.golden.json +0 -0
  25. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_defaults.py +0 -0
  26. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_extract_db.py +0 -0
  27. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_language_detection.py +0 -0
  28. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_partitioning_snapshot.py +0 -0
  29. {fluidattacks_core_filesystem-15.0.0 → fluidattacks_core_filesystem-16.0.0}/tests/test_parts.py +0 -0
@@ -0,0 +1 @@
1
+ use flake ../../..#fluidattacks-core-filesystem
@@ -0,0 +1,16 @@
1
+ dist/
2
+ dist-*/
3
+ *.zip
4
+ /utils/terraform-modules/**/.terraform.lock.hcl
5
+ /.cargo-cache
6
+ target/
7
+ .direnv/
8
+ .venv/
9
+ .vscode/settings.json
10
+ .zed/settings.json
11
+ # each package's shell writes its own, beside the package rather than here
12
+ **/.vscode/settings.json
13
+ *.so
14
+ # the publish check builds into its own tree per architecture
15
+ target-*/
16
+ /develops-ci-analytics-output
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks_core_filesystem
3
- Version: 15.0.0
3
+ Version: 16.0.0
4
4
  Summary: Fluid Attacks Core Filesystem Library
5
5
  Author-email: Development <development@fluidattacks.com>
6
6
  License: MPL-2.0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from fnmatch import fnmatchcase
6
+ from pathlib import PurePosixPath
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .defaults import CARGO_MAX_PATTERNS
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+
15
+ __all__ = [
16
+ "CARGO_MANIFEST",
17
+ "CargoWorkspace",
18
+ "is_cargo_manifest",
19
+ "is_workspace_member",
20
+ "parse_cargo_workspace",
21
+ ]
22
+
23
+ CARGO_MANIFEST = "Cargo.toml"
24
+
25
+ _FOLDED_MANIFEST = CARGO_MANIFEST.casefold()
26
+
27
+ _Components = tuple[str, ...]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CargoWorkspace:
32
+ members: tuple[_Components, ...]
33
+ exclude: tuple[_Components, ...]
34
+
35
+
36
+ def is_cargo_manifest(name: str) -> bool:
37
+ return name.casefold() == _FOLDED_MANIFEST
38
+
39
+
40
+ def _components(path: str) -> _Components | None:
41
+ pure = PurePosixPath(path)
42
+ if pure.is_absolute() or ".." in pure.parts:
43
+ return None
44
+ return pure.parts
45
+
46
+
47
+ def _patterns(value: object) -> tuple[_Components, ...] | None:
48
+ if not isinstance(value, list) or len(value) > CARGO_MAX_PATTERNS:
49
+ return None
50
+ items = tuple(item for item in value if isinstance(item, str))
51
+ if len(items) != len(value):
52
+ return None
53
+ parsed = (_components(item) for item in items)
54
+ return tuple(parts for parts in parsed if parts is not None)
55
+
56
+
57
+ def parse_cargo_workspace(text: str) -> CargoWorkspace | None:
58
+ try:
59
+ document = tomllib.loads(text)
60
+ except (ValueError, RecursionError):
61
+ return None
62
+ table = document.get("workspace")
63
+ if not isinstance(table, dict):
64
+ return None
65
+ members = _patterns(table.get("members", []))
66
+ exclude = _patterns(table.get("exclude", []))
67
+ if members is None or exclude is None:
68
+ return None
69
+ return CargoWorkspace(members=members, exclude=exclude)
70
+
71
+
72
+ def _glob_matches(pattern: _Components, path: _Components) -> bool:
73
+ reachable = frozenset({0})
74
+ for head in pattern:
75
+ if not reachable:
76
+ return False
77
+ if head == "**":
78
+ reachable = frozenset(range(min(reachable), len(path) + 1))
79
+ else:
80
+ reachable = frozenset(
81
+ index + 1
82
+ for index in reachable
83
+ if index < len(path) and fnmatchcase(path[index], head)
84
+ )
85
+ return len(path) in reachable
86
+
87
+
88
+ def _excludes(workspace: CargoWorkspace, relative: _Components) -> bool:
89
+ manifest = (*relative, CARGO_MANIFEST)
90
+ return any(manifest[: len(entry)] == entry for entry in workspace.exclude) and not any(
91
+ manifest[: len(member)] == member for member in workspace.members
92
+ )
93
+
94
+
95
+ def is_workspace_member(
96
+ directory: Path,
97
+ workspaces: Mapping[Path, CargoWorkspace],
98
+ root: Path,
99
+ ) -> bool:
100
+ if directory in workspaces or directory == root:
101
+ return False
102
+ for ancestor in directory.parents:
103
+ workspace = workspaces.get(ancestor)
104
+ if workspace is not None:
105
+ relative = directory.relative_to(ancestor).parts
106
+ if not _excludes(workspace, relative):
107
+ return any(_glob_matches(member, relative) for member in workspace.members)
108
+ if ancestor == root:
109
+ return False
110
+ return False
@@ -3,11 +3,13 @@ from typing import TypedDict
3
3
  from .language import Language
4
4
 
5
5
  __all__ = [
6
+ "CARGO_MAX_PATTERNS",
6
7
  "CONFIG_MARKERS",
7
8
  "EXCLUDE_DIRS",
8
9
  "EXCLUDE_FILES",
9
10
  "EXTENDED_EXCLUDE_DIRS",
10
11
  "MANIFEST_MARKERS",
12
+ "MANIFEST_MAX_BYTES",
11
13
  "SKIP_DIRS",
12
14
  "TEST_DATA_DIRS",
13
15
  "TEST_FILES",
@@ -31,6 +33,10 @@ class WorkspaceSpec(TypedDict):
31
33
 
32
34
  BUNDLE_DIR_GLOBS = ["*.xcodeproj", "*.xcworkspace"]
33
35
 
36
+ MANIFEST_MAX_BYTES = 50 * 1024
37
+
38
+ CARGO_MAX_PATTERNS = 1000
39
+
34
40
 
35
41
  WORKSPACE_MARKERS: dict[str, WorkspaceSpec] = {
36
42
  "gradle": {
@@ -10,7 +10,18 @@ from fnmatch import fnmatch, fnmatchcase
10
10
  from pathlib import Path
11
11
  from typing import TYPE_CHECKING
12
12
 
13
- from .defaults import BUNDLE_DIR_GLOBS, EXTENDED_EXCLUDE_DIRS, TEST_SCAFFOLD_GLOBS
13
+ from .cargo import (
14
+ CargoWorkspace,
15
+ is_cargo_manifest,
16
+ is_workspace_member,
17
+ parse_cargo_workspace,
18
+ )
19
+ from .defaults import (
20
+ BUNDLE_DIR_GLOBS,
21
+ EXTENDED_EXCLUDE_DIRS,
22
+ MANIFEST_MAX_BYTES,
23
+ TEST_SCAFFOLD_GLOBS,
24
+ )
14
25
  from .detection import (
15
26
  declared_workspaces,
16
27
  detect_anchor_language,
@@ -91,6 +102,45 @@ def _claiming_kinds(dir_path: Path, workspaces: Mapping[Path, frozenset[str]]) -
91
102
  return frozenset(kinds)
92
103
 
93
104
 
105
+ def _cargo_workspaces(cargo_manifests: Mapping[Path, str]) -> Mapping[Path, CargoWorkspace]:
106
+ return {
107
+ dir_path: workspace
108
+ for dir_path, text in cargo_manifests.items()
109
+ if (workspace := parse_cargo_workspace(text)) is not None
110
+ }
111
+
112
+
113
+ def _foreign_part_between(
114
+ dir_path: Path,
115
+ workspaces: Mapping[Path, CargoWorkspace],
116
+ names_by_dir: Mapping[Path, tuple[str, ...]],
117
+ ) -> bool:
118
+ for ancestor in dir_path.parents:
119
+ if ancestor in workspaces:
120
+ return False
121
+ names = [name for name in names_by_dir.get(ancestor, ()) if not is_cargo_manifest(name)]
122
+ if detect_anchor_language(names) is not None:
123
+ return True
124
+ return False
125
+
126
+
127
+ def _cargo_claimed_dirs(
128
+ listing: DirListing,
129
+ cargo_manifests: Mapping[Path, str],
130
+ root_path: Path,
131
+ ) -> frozenset[Path]:
132
+ workspaces = _cargo_workspaces(cargo_manifests)
133
+ names_by_dir = dict(listing)
134
+ return frozenset(
135
+ dir_path
136
+ for dir_path, filenames in listing
137
+ if workspaces
138
+ and any(is_cargo_manifest(name) for name in filenames)
139
+ and is_workspace_member(dir_path, workspaces, root_path)
140
+ and not _foreign_part_between(dir_path, workspaces, names_by_dir)
141
+ )
142
+
143
+
94
144
  def _is_bundle_name(name: str) -> bool:
95
145
  return any(fnmatch(name, pattern) for pattern in BUNDLE_DIR_GLOBS)
96
146
 
@@ -123,24 +173,33 @@ def _anchor_names(
123
173
  dir_path: Path,
124
174
  filenames: Sequence[str],
125
175
  workspaces: Mapping[Path, frozenset[str]],
176
+ cargo_claimed: frozenset[Path],
126
177
  root_path: Path,
127
178
  ) -> tuple[str, ...]:
128
179
  if _within_bundle(dir_path, root_path):
129
180
  return ()
130
181
  kinds = _claiming_kinds(dir_path, workspaces)
131
- if not kinds:
132
- return tuple(filenames)
133
- return tuple(name for name in filenames if not is_claimed_manifest(name, kinds))
182
+ claims_cargo = dir_path in cargo_claimed
183
+ return tuple(
184
+ name
185
+ for name in filenames
186
+ if not is_claimed_manifest(name, kinds) and not (claims_cargo and is_cargo_manifest(name))
187
+ )
134
188
 
135
189
 
136
- def _discover_potential_parts(listing: DirListing, root_path: Path) -> tuple[PotentialPart, ...]:
190
+ def _discover_potential_parts(
191
+ listing: DirListing,
192
+ root_path: Path,
193
+ cargo_manifests: Mapping[Path, str],
194
+ ) -> tuple[PotentialPart, ...]:
137
195
  workspaces = _workspace_kinds_by_dir(listing)
196
+ cargo_claimed = _cargo_claimed_dirs(listing, cargo_manifests, root_path)
138
197
  return tuple(
139
198
  (dir_path, _find_config_files(dir_path, filenames), language.value)
140
199
  for dir_path, filenames in listing
141
200
  if (
142
201
  language := detect_anchor_language(
143
- _anchor_names(dir_path, filenames, workspaces, root_path)
202
+ _anchor_names(dir_path, filenames, workspaces, cargo_claimed, root_path)
144
203
  )
145
204
  )
146
205
  is not None
@@ -262,8 +321,13 @@ def _build_single_root_tree(root_path: Path, listing: DirListing) -> PartTree:
262
321
  )
263
322
 
264
323
 
265
- def decide_part_tree(root_path: Path, listing: DirListing) -> PartTree:
266
- potential_parts = _discover_potential_parts(listing, root_path)
324
+ def decide_part_tree(
325
+ root_path: Path,
326
+ listing: DirListing,
327
+ *,
328
+ cargo_manifests: Mapping[Path, str] | None = None,
329
+ ) -> PartTree:
330
+ potential_parts = _discover_potential_parts(listing, root_path, cargo_manifests or {})
267
331
  if not potential_parts:
268
332
  return _build_single_root_tree(root_path, listing)
269
333
 
@@ -315,6 +379,26 @@ def _walk_listing(root_path: Path, exclude_dirs: Collection[str]) -> DirListing:
315
379
  return tuple(listing)
316
380
 
317
381
 
382
+ def _read_manifest(path: Path, root_path: Path) -> str | None:
383
+ try:
384
+ if not path.resolve().is_relative_to(root_path):
385
+ return None
386
+ with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), "rb") as handle:
387
+ data = handle.read(MANIFEST_MAX_BYTES + 1)
388
+ return None if len(data) > MANIFEST_MAX_BYTES else data.decode("utf-8")
389
+ except (OSError, UnicodeDecodeError):
390
+ return None
391
+
392
+
393
+ def _read_cargo_manifests(listing: DirListing, root_path: Path) -> Mapping[Path, str]:
394
+ return {
395
+ dir_path: text
396
+ for dir_path, filenames in listing
397
+ for name in filenames
398
+ if is_cargo_manifest(name) and (text := _read_manifest(dir_path / name, root_path))
399
+ }
400
+
401
+
318
402
  def build_part_tree(root_path: Path, extra_excludes: Sequence[str] | None = None) -> PartTree:
319
403
  resolved = Path(root_path).resolve()
320
404
  if not resolved.is_dir():
@@ -327,4 +411,5 @@ def build_part_tree(root_path: Path, extra_excludes: Sequence[str] | None = None
327
411
  msg = f"Root path could not be read: {root_path}"
328
412
  raise OSError(msg)
329
413
 
330
- return decide_part_tree(resolved, listing)
414
+ manifests = _read_cargo_manifests(listing, resolved)
415
+ return decide_part_tree(resolved, listing, cargo_manifests=manifests)
@@ -11,6 +11,6 @@
11
11
  layers =
12
12
  partitioning : projects
13
13
  parts
14
- detection
14
+ detection | cargo
15
15
  defaults
16
16
  language
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks_core_filesystem"
3
- version = "15.0.0"
3
+ version = "16.0.0"
4
4
  description = "Fluid Attacks Core Filesystem Library"
5
5
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]
6
6
  license = { text = "MPL-2.0" }
@@ -35,6 +35,9 @@ indent-style = "space"
35
35
  skip-magic-trailing-comma = false
36
36
  line-ending = "lf"
37
37
 
38
+ [lint.isort]
39
+ known-first-party = ["fluidattacks_core"]
40
+
38
41
  [lint.mccabe]
39
42
  max-complexity = 8
40
43
 
@@ -0,0 +1,122 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from fluidattacks_core.filesystem.cargo import (
7
+ CargoWorkspace,
8
+ is_cargo_manifest,
9
+ is_workspace_member,
10
+ parse_cargo_workspace,
11
+ )
12
+ from fluidattacks_core.filesystem.defaults import CARGO_MAX_PATTERNS
13
+
14
+ _ROOT = Path("/component")
15
+
16
+
17
+ def _workspace(members: list[str], exclude: list[str] | None = None) -> CargoWorkspace:
18
+ text = f"[workspace]\nmembers = {json.dumps(members)}\nexclude = {json.dumps(exclude or [])}"
19
+ workspace = parse_cargo_workspace(text)
20
+ assert workspace is not None
21
+ return workspace
22
+
23
+
24
+ def _is_member(path: str, members: list[str], exclude: list[str] | None = None) -> bool:
25
+ return is_workspace_member(_ROOT / path, {_ROOT: _workspace(members, exclude)}, _ROOT)
26
+
27
+
28
+ def test_a_workspace_table_parses_into_path_components_once() -> None:
29
+ text = '[workspace]\nmembers = ["./crates/*/", "../x", "/abs"]\nexclude = ["crates/old"]'
30
+
31
+ assert parse_cargo_workspace(text) == CargoWorkspace(
32
+ members=(("crates", "*"),), exclude=(("crates", "old"),)
33
+ )
34
+ assert parse_cargo_workspace("[workspace]") == CargoWorkspace(members=(), exclude=())
35
+
36
+
37
+ @pytest.mark.parametrize(
38
+ "text",
39
+ [
40
+ "x = " + "[" * 5000 + "]" * 5000,
41
+ "x = " + "9" * 5000,
42
+ "[workspace",
43
+ "workspace = 1",
44
+ '[workspace]\nmembers = "crates/*"',
45
+ "[workspace]\nmembers = [1]",
46
+ f"[workspace]\nmembers = {json.dumps(['a'] * (CARGO_MAX_PATTERNS + 1))}",
47
+ f"[workspace]\nexclude = {json.dumps(['a'] * (CARGO_MAX_PATTERNS + 1))}",
48
+ ],
49
+ )
50
+ def test_a_hostile_or_malformed_manifest_is_no_workspace(text: str) -> None:
51
+ assert parse_cargo_workspace(text) is None
52
+
53
+
54
+ def test_a_workspace_at_the_pattern_cap_still_parses() -> None:
55
+ workspace = _workspace(["a"] * CARGO_MAX_PATTERNS)
56
+
57
+ assert len(workspace.members) == CARGO_MAX_PATTERNS
58
+
59
+
60
+ def test_the_manifest_name_folds_case() -> None:
61
+ assert is_cargo_manifest("cargo.TOML")
62
+ assert not is_cargo_manifest("Cargo.lock")
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ ("pattern", "path", "expected"),
67
+ [
68
+ ("crates/*", "crates/domain", True),
69
+ ("crates/*", "crates/domain/sub", False),
70
+ ("**/inner", "inner", True),
71
+ ("**/inner", "a/b/inner", True),
72
+ ("crate-?", "crate-a", True),
73
+ ("crate-?", "crate-ab", False),
74
+ ("crate-[ab]", "crate-b", True),
75
+ ("crate-[!ab]", "crate-b", False),
76
+ ("Crates/*", "crates/domain", False),
77
+ ],
78
+ )
79
+ def test_a_member_pattern_matches_whole_components(
80
+ pattern: str, path: str, *, expected: bool
81
+ ) -> None:
82
+ assert _is_member(path, [pattern]) is expected
83
+
84
+
85
+ def test_many_recursive_wildcards_match_in_linear_time() -> None:
86
+ assert _is_member("a/x", ["**/" * 3000 + "x"])
87
+ assert not _is_member("/".join(["d"] * 20), ["/".join(["**"] * 12) + "/zz"])
88
+
89
+
90
+ def test_an_exclusion_is_a_literal_path_prefix() -> None:
91
+ assert not _is_member("crates/old", ["crates/*"], ["crates/old"])
92
+ assert not _is_member("crates/old", ["crates/*"], ["crates/old/Cargo.toml"])
93
+ assert _is_member("crates/old", ["crates/*"], ["crates/*"])
94
+ assert _is_member("crates/old", ["crates/*"], ["crates/ol"])
95
+
96
+
97
+ def test_only_a_literal_member_overrides_an_exclusion() -> None:
98
+ assert not _is_member("crates/old", ["crates/*"], ["crates"])
99
+ assert _is_member("crates/old", ["crates/*", "crates/old"], ["crates"])
100
+
101
+
102
+ def test_an_excluded_crate_is_claimed_by_an_outer_workspace() -> None:
103
+ workspaces = {
104
+ _ROOT: _workspace(["tools/gen"]),
105
+ _ROOT / "tools": _workspace(["*"], ["gen"]),
106
+ }
107
+
108
+ assert is_workspace_member(_ROOT / "tools" / "gen", workspaces, _ROOT)
109
+
110
+
111
+ def test_a_workspace_root_and_the_root_are_never_members() -> None:
112
+ workspaces = {_ROOT: _workspace(["**"]), _ROOT / "nested": _workspace(["inner"])}
113
+
114
+ assert not is_workspace_member(_ROOT / "nested", workspaces, _ROOT)
115
+ assert not is_workspace_member(_ROOT, workspaces, _ROOT)
116
+ assert is_workspace_member(_ROOT / "nested" / "inner", workspaces, _ROOT)
117
+
118
+
119
+ def test_no_workspace_above_the_root_is_consulted() -> None:
120
+ workspaces = {Path("/"): _workspace(["**"])}
121
+
122
+ assert not is_workspace_member(_ROOT / "lib", workspaces, _ROOT)
@@ -4,7 +4,11 @@ from pathlib import Path
4
4
  import pytest
5
5
 
6
6
  from fluidattacks_core.filesystem import build_part_tree
7
- from fluidattacks_core.filesystem.defaults import MANIFEST_MARKERS, TEST_SCAFFOLD_GLOBS
7
+ from fluidattacks_core.filesystem.defaults import (
8
+ MANIFEST_MARKERS,
9
+ MANIFEST_MAX_BYTES,
10
+ TEST_SCAFFOLD_GLOBS,
11
+ )
8
12
  from fluidattacks_core.filesystem.partitioning import (
9
13
  DirListing,
10
14
  PotentialPart,
@@ -17,6 +21,7 @@ from fluidattacks_core.filesystem.partitioning import (
17
21
  _is_scaffold_name,
18
22
  _normalized_dir_name,
19
23
  _owning_part,
24
+ _read_manifest,
20
25
  decide_part_tree,
21
26
  )
22
27
 
@@ -117,7 +122,7 @@ def test_config_files_are_found_by_name_and_glob() -> None:
117
122
 
118
123
 
119
124
  def test_only_directories_holding_a_manifest_become_parts() -> None:
120
- discovered = _discover_potential_parts(_LISTING, _ROOT)
125
+ discovered = _discover_potential_parts(_LISTING, _ROOT, {})
121
126
  assert [(path, language) for path, _, language in discovered] == [
122
127
  (_ROOT, "Python"),
123
128
  (_ROOT / "web", "JavaScript"),
@@ -125,7 +130,8 @@ def test_only_directories_holding_a_manifest_become_parts() -> None:
125
130
 
126
131
 
127
132
  def test_a_lone_hint_does_not_make_a_potential_part() -> None:
128
- assert _discover_potential_parts(((_ROOT / "tools", ("mypy.ini", "tox.ini")),), _ROOT) == ()
133
+ listing = ((_ROOT / "tools", ("mypy.ini", "tox.ini")),)
134
+ assert _discover_potential_parts(listing, _ROOT, {}) == ()
129
135
 
130
136
 
131
137
  def test_hierarchy_nests_parts_under_their_nearest_ancestor() -> None:
@@ -352,6 +358,137 @@ def test_a_gradle_module_without_a_settings_file_anchors_alone() -> None:
352
358
  assert sorted(tree.parts) == [".", "deployment/acceptance-test"]
353
359
 
354
360
 
361
+ _CARGO_ROOT = Path("/component")
362
+
363
+ _CARGO_LISTING = (
364
+ (_CARGO_ROOT, ("Cargo.toml", "Cargo.lock")),
365
+ (_CARGO_ROOT / "crates" / "domain", ("Cargo.toml",)),
366
+ (_CARGO_ROOT / "crates" / "domain" / "src", ("lib.rs",)),
367
+ (_CARGO_ROOT / "crates" / "shell", ("Cargo.toml",)),
368
+ )
369
+
370
+
371
+ def _cargo_parts(listing: DirListing, manifests: dict[Path, str]) -> list[str]:
372
+ return sorted(decide_part_tree(_CARGO_ROOT, listing, cargo_manifests=manifests).parts)
373
+
374
+
375
+ def test_a_cargo_workspace_folds_its_members_into_one_part() -> None:
376
+ manifests = {_CARGO_ROOT: '[workspace]\nmembers = ["crates/*"]'}
377
+
378
+ tree = decide_part_tree(_CARGO_ROOT, _CARGO_LISTING, cargo_manifests=manifests)
379
+
380
+ assert sorted(tree.parts) == ["."]
381
+ root = tree.root
382
+ assert root is not None
383
+ assert root.detected_language == "rust"
384
+ assert Path("crates/domain/src/lib.rs") in root.files
385
+ assert Path("crates/shell/Cargo.toml") in root.files
386
+
387
+
388
+ def test_a_root_package_keeps_a_crate_it_does_not_list() -> None:
389
+ manifest = '[package]\nname = "app"\n[workspace]\nmembers = ["crates/domain"]'
390
+
391
+ assert _cargo_parts(_CARGO_LISTING, {_CARGO_ROOT: manifest}) == [".", "crates/shell"]
392
+
393
+
394
+ @pytest.mark.parametrize(
395
+ "manifest",
396
+ [
397
+ '[package]\nname = "app"',
398
+ "[workspace",
399
+ "workspace = 1",
400
+ '[workspace]\nmembers = "crates/*"',
401
+ "[workspace]\nmembers = [1]",
402
+ '[workspace]\nmembers = ["crates/*"]\nexclude = "crates/shell"',
403
+ ],
404
+ )
405
+ def test_a_root_without_a_usable_workspace_changes_nothing(manifest: str) -> None:
406
+ expected = [".", "crates/domain", "crates/shell"]
407
+
408
+ assert _cargo_parts(_CARGO_LISTING, {_CARGO_ROOT: manifest}) == expected
409
+ assert sorted(decide_part_tree(_CARGO_ROOT, _CARGO_LISTING).parts) == expected
410
+
411
+
412
+ def test_a_nested_cargo_workspace_anchors_and_claims_its_own_members() -> None:
413
+ listing = (
414
+ (_CARGO_ROOT, ("Cargo.toml",)),
415
+ (_CARGO_ROOT / "crates" / "nested", ("Cargo.toml",)),
416
+ (_CARGO_ROOT / "crates" / "nested" / "inner", ("Cargo.toml",)),
417
+ )
418
+ manifests = {
419
+ _CARGO_ROOT: '[workspace]\nmembers = ["crates/*"]',
420
+ _CARGO_ROOT / "crates" / "nested": '[workspace]\nmembers = ["inner"]',
421
+ }
422
+
423
+ assert _cargo_parts(listing, manifests) == [".", "crates/nested"]
424
+
425
+
426
+ def test_a_member_under_a_foreign_part_keeps_its_own_part() -> None:
427
+ desktop = _CARGO_ROOT / "apps" / "desktop"
428
+ listing = (
429
+ (_CARGO_ROOT, ("Cargo.toml",)),
430
+ (desktop, ("package.json",)),
431
+ (desktop / "src-tauri", ("Cargo.toml",)),
432
+ )
433
+ manifest = '[workspace]\nmembers = ["apps/desktop/src-tauri"]'
434
+ expected = [".", "apps/desktop", "apps/desktop/src-tauri"]
435
+
436
+ assert _cargo_parts(listing, {_CARGO_ROOT: manifest}) == expected
437
+
438
+
439
+ def test_a_cargo_workspace_does_not_claim_a_foreign_manifest() -> None:
440
+ listing = (
441
+ (_CARGO_ROOT, ("Cargo.toml",)),
442
+ (_CARGO_ROOT / "crates" / "web", ("Cargo.toml", "package.json")),
443
+ )
444
+
445
+ tree = decide_part_tree(
446
+ _CARGO_ROOT, listing, cargo_manifests={_CARGO_ROOT: '[workspace]\nmembers = ["crates/*"]'}
447
+ )
448
+
449
+ web = tree.get_part("crates/web")
450
+ assert web is not None
451
+ assert web.detected_language == "JavaScript"
452
+
453
+
454
+ _WORKSPACE = b'[workspace]\nmembers = ["crates/*"]'
455
+
456
+
457
+ def _write_workspace(root: Path, manifest: bytes) -> None:
458
+ (root / "crates" / "lib").mkdir(parents=True)
459
+ (root / "crates" / "lib" / "Cargo.toml").write_text('[package]\nname = "lib"')
460
+ (root / "Cargo.toml").write_bytes(manifest)
461
+
462
+
463
+ def test_build_part_tree_reads_a_cargo_workspace_from_disk(tmp_path: Path) -> None:
464
+ _write_workspace(tmp_path, _WORKSPACE)
465
+
466
+ assert sorted(build_part_tree(tmp_path).parts) == ["."]
467
+
468
+
469
+ @pytest.mark.parametrize(
470
+ "manifest",
471
+ [_WORKSPACE + b"\n#" + b"x" * MANIFEST_MAX_BYTES, b"\xff" + _WORKSPACE],
472
+ )
473
+ def test_an_oversized_or_undecodable_cargo_root_folds_nothing(
474
+ tmp_path: Path, manifest: bytes
475
+ ) -> None:
476
+ _write_workspace(tmp_path, manifest)
477
+
478
+ assert sorted(build_part_tree(tmp_path).parts) == [".", "crates/lib"]
479
+
480
+
481
+ @pytest.mark.parametrize("inside", [True, False])
482
+ def test_a_symlinked_manifest_is_never_read(tmp_path: Path, *, inside: bool) -> None:
483
+ root = tmp_path / "repo"
484
+ root.mkdir()
485
+ target = (root if inside else tmp_path) / "real.toml"
486
+ target.write_bytes(_WORKSPACE)
487
+ (root / "Cargo.toml").symlink_to(target)
488
+
489
+ assert _read_manifest(root / "Cargo.toml", root.resolve()) is None
490
+
491
+
355
492
  def test_an_xcode_bundle_does_not_anchor_beside_its_project() -> None:
356
493
  listing = (
357
494
  (_ROOT, ("pubspec.yaml",)),
@@ -273,6 +273,15 @@ def test_no_marker_name_is_excluded_as_an_artifact() -> None:
273
273
  assert excluded == []
274
274
 
275
275
 
276
+ def test_find_projects_still_splits_a_cargo_workspace(tmp_path: Path) -> None:
277
+ (tmp_path / "lib").mkdir()
278
+ (tmp_path / "lib" / "Cargo.toml").write_text('[package]\nname = "lib"')
279
+ (tmp_path / "Cargo.toml").write_text('[workspace]\nmembers = ["lib"]')
280
+
281
+ assert _by_path(find_projects(tmp_path), tmp_path.resolve()) == {".": "rust", "lib": "rust"}
282
+ assert sorted(build_part_tree(tmp_path).parts) == ["."]
283
+
284
+
276
285
  def test_find_projects_still_splits_a_gradle_workspace(tmp_path: Path) -> None:
277
286
  _make(tmp_path, ["settings.gradle", "build.gradle", "mod/build.gradle"])
278
287
  root = tmp_path.resolve()
@@ -178,7 +178,7 @@ wheels = [
178
178
 
179
179
  [[package]]
180
180
  name = "fluidattacks-core-filesystem"
181
- version = "15.0.0"
181
+ version = "16.0.0"
182
182
  source = { editable = "." }
183
183
 
184
184
  [package.dev-dependencies]
@@ -1 +0,0 @@
1
- use_flake ".#python311.devShell"
@@ -1,9 +0,0 @@
1
- dist/
2
- dist-*/
3
- /.cargo-cache
4
- target/
5
- .direnv/
6
- .venv/
7
- .vscode/settings.json
8
- .zed/settings.json
9
- *.so