Skip to content

Commit 49a80b6

Browse files
committed
wip feature
1 parent 57008b9 commit 49a80b6

4 files changed

Lines changed: 178 additions & 53 deletions

File tree

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ repos:
1515
- id: isort
1616
name: Sort import
1717
entry: dfetch
18-
args: ['filter', 'isort']
18+
args: ['filter','--not-dfetched', 'isort']
1919
language: system
2020
types: [file, python]
2121

2222
- id: black
2323
name: Black (auto-format)
2424
entry: dfetch
25-
args: ['filter', 'black']
25+
args: ['filter', '--not-dfetched', 'black']
2626
language: system
2727
types: [file, python]
2828

@@ -102,7 +102,7 @@ repos:
102102
name: codespell
103103
description: Checks for common misspellings in text files.
104104
entry: dfetch
105-
args: ['filter', 'codespell']
105+
args: ['filter', '--not-dfetched','codespell']
106106
language: python
107107
# exclude: ^doc/_ext/sphinxcontrib_asciinema/_static/asciinema-player_3.12.1.js
108108
types: [text]

dfetch/commands/filter.py

Lines changed: 112 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import argparse
88
import os
99
import sys
10+
from enum import Enum
1011
from pathlib import Path
1112
from typing import Optional
1213

@@ -20,6 +21,14 @@
2021
logger = get_logger(__name__)
2122

2223

24+
class FilterType(Enum):
25+
"""Types of filtering."""
26+
27+
BLOCK_ONLY_PATH_TRAVERSAL = 0
28+
BLOCK_IF_INSIDE = 1
29+
BLOCK_IF_OUTSIDE = 2
30+
31+
2332
class Filter(dfetch.commands.command.Command):
2433
"""Filter files based on flags and pass on any command.
2534
@@ -33,11 +42,19 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None
3342
"""Add the parser menu for this action."""
3443
parser = dfetch.commands.command.Command.parser(subparsers, Filter)
3544
parser.add_argument(
36-
"--in-manifest",
37-
"-i",
45+
"--dfetched",
46+
"-D",
47+
action="store_true",
48+
default=True,
49+
help="Keep files that came here by dfetching them.",
50+
)
51+
52+
parser.add_argument(
53+
"--not-dfetched",
54+
"-N",
3855
action="store_true",
3956
default=False,
40-
help="Keep files that came here through the manifest.",
57+
help="Keep files that did not came here by dfetching them.",
4158
)
4259

4360
parser.add_argument(
@@ -60,83 +77,128 @@ def __call__(self, args: argparse.Namespace) -> None:
6077
"""Perform the filter."""
6178
if not args.verbose:
6279
dfetch.log.set_level("ERROR")
63-
manifest = dfetch.manifest.manifest.get_manifest()
6480

65-
pwd = Path.cwd()
81+
argument_list = self._get_arguments(args)
82+
83+
manifest = dfetch.manifest.manifest.get_manifest()
6684
topdir = Path(manifest.path).parent
67-
with in_directory(topdir):
6885

69-
project_paths = {
86+
resolved_args = self._resolve_args(argument_list, topdir)
87+
88+
with in_directory(topdir):
89+
abs_project_paths = {
7090
Path(project.destination).resolve() for project in manifest.projects
7191
}
7292

73-
input_list = self._determine_input_list(args)
74-
block_inside, block_outside = self._filter_files(
75-
pwd, topdir, project_paths, input_list
76-
)
77-
78-
blocklist = block_outside if args.in_manifest else block_inside
93+
if args.dfetched and not args.not_dfetched:
94+
block_type = FilterType.BLOCK_IF_OUTSIDE
95+
elif args.not_dfetched:
96+
block_type = FilterType.BLOCK_IF_INSIDE
97+
else:
98+
block_type = FilterType.BLOCK_ONLY_PATH_TRAVERSAL
7999

80-
filtered_args = [arg for arg in input_list if arg not in blocklist]
100+
filtered_args = self._filter_args(
101+
topdir, resolved_args, abs_project_paths, block_type
102+
)
81103

82104
if args.cmd:
83105
run_on_cmdline_uncaptured(logger, [args.cmd] + filtered_args)
84106
else:
85107
print(os.linesep.join(filtered_args))
86108

87-
def _determine_input_list(self, args: argparse.Namespace) -> list[str]:
88-
"""Determine list of inputs to process."""
89-
input_list: list[str] = list(str(arg) for arg in args.args)
90-
if not sys.stdin.isatty():
91-
input_list += list(str(arg).strip() for arg in sys.stdin.readlines())
109+
def _filter_args(
110+
self,
111+
topdir: Path,
112+
resolved_args: dict[str, Optional[Path]],
113+
abs_project_paths: set[Path],
114+
block: FilterType,
115+
) -> list[str]:
116+
blocklist = self._filter_files(
117+
topdir,
118+
abs_project_paths,
119+
{path for path in resolved_args.values() if path},
120+
block,
121+
)
92122

93-
# If no input from stdin or args loop over all files
94-
if not input_list:
95-
input_list = list(
96-
str(file) for file in Path(".").rglob("*") if file.is_file()
123+
filtered_args = [
124+
arg for arg in resolved_args.keys() if resolved_args[arg] not in blocklist
125+
]
126+
127+
return filtered_args
128+
129+
def _resolve_args(
130+
self, argument_list: list[str], topdir: Path
131+
) -> dict[str, Optional[Path]]:
132+
resolved_args: dict[str, Optional[Path]] = {}
133+
if argument_list:
134+
for argument in argument_list:
135+
path_obj = Path(argument.strip())
136+
resolved_args[argument] = (
137+
path_obj.resolve() if path_obj.exists() else None
138+
)
139+
else:
140+
if not argument_list:
141+
resolved_args = {
142+
str(file): file.resolve()
143+
for file in topdir.rglob("*")
144+
if ".git" not in file.parts
145+
}
146+
147+
return resolved_args
148+
149+
def _get_arguments(self, args: argparse.Namespace) -> list[str]:
150+
argument_list: list[str] = list(str(arg) for arg in args.args)
151+
if not sys.stdin.isatty():
152+
argument_list.extend(
153+
non_empty_line for line in sys.stdin if (non_empty_line := line.strip())
97154
)
98155

99-
return input_list
156+
return argument_list
100157

101158
def _filter_files(
102-
self, pwd: Path, topdir: Path, project_paths: set[Path], input_list: list[str]
103-
) -> tuple[list[str], list[str]]:
104-
"""Filter files in input_set in files in one of the project_paths or not."""
105-
block_inside: list[str] = []
106-
block_outside: list[str] = []
107-
108-
for path_or_arg in input_list:
109-
arg_abs_path = Path(pwd / path_or_arg.strip()).resolve()
110-
if not arg_abs_path.exists():
111-
logger.print_info_line(path_or_arg.strip(), "not a file / dir")
112-
continue
159+
self,
160+
topdir: Path,
161+
paths: set[Path],
162+
input_paths: set[Path],
163+
block: FilterType = FilterType.BLOCK_IF_OUTSIDE,
164+
) -> list[Path]:
165+
"""Filter files in input_set in files in one of the paths or not."""
166+
blocklist: list[Path] = []
167+
168+
for abs_path in input_paths:
113169
try:
114-
arg_abs_path.relative_to(topdir)
170+
abs_path.relative_to(topdir)
115171
except ValueError:
116-
logger.print_info_line(path_or_arg.strip(), "outside project")
117-
block_inside.append(path_or_arg)
118-
block_outside.append(path_or_arg)
172+
logger.print_info_line(str(abs_path), "outside project")
173+
blocklist.append(abs_path)
174+
continue
175+
176+
if block == FilterType.BLOCK_ONLY_PATH_TRAVERSAL:
119177
continue
120178

121-
containing_dir = self._file_in_project(arg_abs_path, project_paths)
179+
containing_dir = self._is_file_contained_in_any_path(abs_path, paths)
122180

123181
if containing_dir:
124-
block_inside.append(path_or_arg)
125182
logger.print_info_line(
126-
path_or_arg.strip(), f"inside project ({containing_dir})"
183+
str(abs_path), f"inside project ({containing_dir})"
127184
)
185+
if block == FilterType.BLOCK_IF_INSIDE:
186+
blocklist.append(abs_path)
128187
else:
129-
block_outside.append(path_or_arg)
130-
logger.print_info_line(path_or_arg.strip(), "not inside any project")
188+
logger.print_info_line(str(abs_path), "not inside any project")
189+
if block == FilterType.BLOCK_IF_OUTSIDE:
190+
blocklist.append(abs_path)
131191

132-
return block_inside, block_outside
192+
return blocklist
133193

134-
def _file_in_project(self, file: Path, project_paths: set[Path]) -> Optional[Path]:
135-
"""Check if a specific file is somewhere in one of the project paths."""
136-
for project_path in project_paths:
194+
def _is_file_contained_in_any_path(
195+
self, file: Path, paths: set[Path]
196+
) -> Optional[Path]:
197+
"""Check if a specific file is somewhere in one of the paths."""
198+
for path in paths:
137199
try:
138-
file.relative_to(project_path)
139-
return project_path
200+
file.relative_to(path)
201+
return path
140202
except ValueError:
141203
continue
142204
return None

features/filter-projects.feature

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
@wip
2+
Feature: Filtering file paths before executing a tool
3+
4+
Projects are dfetched and used in parent projects, users would like to run
5+
static analysis tools but ignore externally vendored projects. The dfetch filter
6+
command makes it possible to wrap a cal to another tool and filter out any external files.
7+
Also it is possible to list all files that are under control of dfetch and this can be used
8+
to automate various tasks. Paths outside the top-level directory should be excluded to prevent
9+
any path traversal.
10+
11+
Background:
12+
Given a git repository "SomeProject.git"
13+
And a fetched and committed MyProject with the manifest
14+
"""
15+
manifest:
16+
version: 0.0
17+
projects:
18+
- name: SomeProject
19+
url: some-remote-server/SomeProject.git
20+
"""
21+
22+
Scenario: Tool receives only managed files
23+
When I run "dfetch filter"
24+
Then the output shows
25+
"""
26+
/some/dir/MyProject/SomeProject
27+
/some/dir/MyProject/SomeProject/README.md
28+
/some/dir/MyProject/SomeProject/.dfetch_data.yaml
29+
"""
30+
31+
# Scenario: Tool receives only unmanaged files
32+
33+
# Scenario: Fail on path traversal outside top-level manifest directory

features/steps/generic_steps.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,32 @@
2525
urn_uuid = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
2626
bom_ref = re.compile(r"BomRef\.[0-9]+\.[0-9]+")
2727
svn_error = re.compile(r"svn: E\d{6}: .+")
28+
abs_path = re.compile(r"/some/dir")
29+
30+
31+
def make_relative_if_path(
32+
some_string: str, base_dir: Union[str, os.PathLike] = "."
33+
) -> str:
34+
"""
35+
Convert a string to a relative path if it's a path, relative to base_dir.
36+
Works even if the path is outside the base_dir.
37+
38+
Args:
39+
some_string (str): String to check.
40+
base_dir (str or Path): Base directory for relative path.
41+
42+
Returns:
43+
str: Relative path if s is a path, otherwise original string.
44+
"""
45+
the_path = pathlib.Path(some_string)
46+
47+
if not the_path.is_absolute():
48+
return some_string
49+
50+
try:
51+
return os.path.relpath(str(the_path), base_dir)
52+
except ValueError:
53+
return some_string
2854

2955

3056
def remote_server_path(context):
@@ -84,6 +110,7 @@ def check_content(
84110
(iso_timestamp, "[timestamp]"),
85111
(urn_uuid, "[urn-uuid]"),
86112
(bom_ref, "[bom-ref]"),
113+
(abs_path, "."),
87114
],
88115
text=expected,
89116
)
@@ -98,6 +125,8 @@ def check_content(
98125
text=actual,
99126
)
100127

128+
actual = make_relative_if_path(actual)
129+
101130
assert actual.strip() == expected.strip(), (
102131
f"Line {line_nr}: Actual >>{actual.strip()}<< != Expected >>{expected.strip()}<<\n"
103132
f"ACTUAL:\n{''.join(actual_content)}"
@@ -170,6 +199,7 @@ def step_impl(context, path=None):
170199
@when('I run "dfetch {args}"')
171200
def step_impl(context, args, path=None):
172201
"""Call a command."""
202+
context.cmd_output = ""
173203
call_command(context, args.split(), path)
174204

175205

0 commit comments

Comments
 (0)