mkdocs-easylinks-plugin 0.2.0__tar.gz → 0.2.2__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 (19) hide show
  1. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.github/workflows/publish.yml +5 -8
  2. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.gitignore +4 -0
  3. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/CHANGELOG.md +21 -0
  4. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/PKG-INFO +2 -3
  5. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks/plugin.py +74 -22
  6. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/PKG-INFO +2 -3
  7. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/pyproject.toml +3 -4
  8. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/tests/test_plugin.py +124 -0
  9. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.github/workflows/test.yml +0 -0
  10. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/LICENSE +0 -0
  11. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/README.md +0 -0
  12. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks/__init__.py +0 -0
  13. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/SOURCES.txt +0 -0
  14. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/dependency_links.txt +0 -0
  15. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/entry_points.txt +0 -0
  16. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/requires.txt +0 -0
  17. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/top_level.txt +0 -0
  18. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/setup.cfg +0 -0
  19. {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/tests/__init__.py +0 -0
@@ -11,6 +11,9 @@ env:
11
11
  jobs:
12
12
  publish:
13
13
  runs-on: ubuntu-latest
14
+ environment: pypi
15
+ permissions:
16
+ id-token: write
14
17
 
15
18
  steps:
16
19
  - uses: actions/checkout@v4
@@ -20,14 +23,8 @@ jobs:
20
23
  with:
21
24
  python-version: "3.12"
22
25
 
23
- - name: Install build tools
24
- run: pip install build twine
25
-
26
26
  - name: Build package
27
- run: python -m build
27
+ run: pip install build && python -m build
28
28
 
29
29
  - name: Publish to PyPI
30
- env:
31
- TWINE_USERNAME: __token__
32
- TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
33
- run: twine upload dist/*
30
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -42,3 +42,7 @@ ENV/
42
42
  # OS
43
43
  .DS_Store
44
44
  Thumbs.db
45
+
46
+ # Local Docker testing (not for remote repo)
47
+ Dockerfile
48
+ requirements.txt
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.2] - 2026-04-13
6
+
7
+ ### Security
8
+ - Fixed log injection via user-controlled filenames
9
+ - Fixed path traversal via malformed file paths
10
+ - Fixed URL scheme bypass allowing dangerous link types
11
+ - Fixed misconfigured `exclude_dirs` empty-string entry
12
+
13
+ ### Performance
14
+ - `_restore_protected_blocks` now uses a single compiled regex substitution instead of N sequential `str.replace` calls, making restoration O(n) in document length
15
+ - `_is_safe_path` short-circuits immediately for paths containing no `..` component (the common case), avoiding the `os.path.normpath` call entirely
16
+ - Config flags and the stats dict are captured as locals before the per-link closure is entered, removing repeated attribute and dict lookups on every link match
17
+ - Relative path results are cached per page so repeated links to the same target file call `_get_relative_path` only once
18
+
19
+ ## [0.2.1] - 2026-04-02
20
+
21
+ ### Fixed
22
+ - Replaced deprecated `license = {text = "MIT"}` with SPDX expression `license = "MIT"`
23
+ - Removed deprecated `License :: OSI Approved :: MIT License` classifier
24
+ - Bumped setuptools build requirement to `>=77.0.0` to support SPDX license format
25
+
5
26
  ## [0.2.0] - 2026-04-02
6
27
 
7
28
  ### Removed
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-easylinks-plugin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: An MkDocs plugin that allows linking to files by filename only
5
5
  Author: Daniel Ferguson
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dsferg/mkdocs-easylinks-plugin
8
8
  Project-URL: Documentation, https://github.com/dsferg/mkdocs-easylinks-plugin
9
9
  Project-URL: Repository, https://github.com/dsferg/mkdocs-easylinks-plugin
10
10
  Keywords: mkdocs,plugin,links,cross-references
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
@@ -17,6 +17,11 @@ from mkdocs.config.defaults import MkDocsConfig
17
17
  logger = logging.getLogger("mkdocs.plugins.easylinks")
18
18
 
19
19
 
20
+ def _sanitize_log(value: str) -> str:
21
+ """Strip control characters from user-controlled strings before logging."""
22
+ return value.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
23
+
24
+
20
25
  class EasyLinksConfig(Config):
21
26
  warn_on_missing = config_options.Type(bool, default=True)
22
27
  warn_on_ambiguous = config_options.Type(bool, default=True)
@@ -61,12 +66,24 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
61
66
  self.stats = {key: 0 for key in self.stats}
62
67
  self.link_counts = defaultdict(int)
63
68
  self._normalized_exclude_dirs = [
64
- d.replace("\\", "/").rstrip("/") + "/" for d in self.config["exclude_dirs"]
69
+ d.replace("\\", "/").rstrip("/") + "/"
70
+ for d in self.config["exclude_dirs"]
71
+ if d # skip empty strings — they would normalize to "/" and exclude everything
65
72
  ]
66
73
 
67
74
  # Process all files (documentation pages, images, etc.)
68
75
  for file in files:
69
76
  self.stats["total_files_scanned"] += 1
77
+
78
+ # Reject paths that escape the docs root (e.g. via symlink traversal)
79
+ if not self._is_safe_path(file.src_path):
80
+ logger.warning(
81
+ f"easylinks: Skipping file with path outside docs root: "
82
+ f"'{_sanitize_log(file.src_path)}'"
83
+ )
84
+ self.stats["files_ignored"] += 1
85
+ continue
86
+
70
87
  filename = os.path.basename(file.src_path)
71
88
 
72
89
  # Ignore files starting with a dot (hidden files)
@@ -96,15 +113,29 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
96
113
  # Warn about ambiguous files
97
114
  if self.config["warn_on_ambiguous"] and self.ambiguous_files:
98
115
  for filename, paths in self.ambiguous_files.items():
116
+ safe_filename = _sanitize_log(filename)
117
+ safe_paths = [_sanitize_log(p) for p in paths]
99
118
  logger.warning(
100
- f"easylinks: Ambiguous filename '{filename}' found in multiple locations:\n"
101
- + "\n".join(f" - {path}" for path in paths)
119
+ f"easylinks: Ambiguous filename '{safe_filename}' found in multiple locations:\n"
120
+ + "\n".join(f" - {p}" for p in safe_paths)
102
121
  + "\nLinks to this file will use the first occurrence. "
103
122
  "Consider using full paths for disambiguation."
104
123
  )
105
124
 
106
125
  return files
107
126
 
127
+ def _is_safe_path(self, src_path: str) -> bool:
128
+ """Return True if src_path stays within the docs root.
129
+
130
+ MkDocs supplies src_path as a relative path (e.g. ``subdir/page.md``).
131
+ A path that escapes the docs root after normalization (e.g.
132
+ ``../../etc/passwd``) would produce a traversal link in the output and
133
+ must be rejected.
134
+ """
135
+ if ".." not in src_path:
136
+ return True
137
+ return not os.path.normpath(src_path).startswith("..")
138
+
108
139
  def _is_excluded_dir(self, file_path: str) -> bool:
109
140
  """Check if a file is in an excluded directory."""
110
141
  if not self._normalized_exclude_dirs:
@@ -128,14 +159,28 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
128
159
  # Extract code fences and HTML comments before processing
129
160
  markdown, protected_blocks = self._extract_protected_blocks(markdown)
130
161
 
162
+ # Capture config flags and stats dict as locals to avoid repeated
163
+ # attribute + dict lookups inside the closure on every link match.
164
+ warn_on_ambiguous = self.config["warn_on_ambiguous"]
165
+ warn_on_missing = self.config["warn_on_missing"]
166
+ stats = self.stats
167
+ page_src_path = page.file.src_path
168
+ # Cache relative-path results within this page: from_path is constant,
169
+ # so keying on to_path alone is sufficient.
170
+ relative_path_cache: Dict[str, str] = {}
171
+
131
172
  def replace_link(match):
132
173
  is_image = match.group(1) # Will be '!' for images, None for regular links
133
174
  link_text = match.group(2)
134
175
  link_url = match.group(3)
135
176
 
136
- # Skip if it's an external link, anchor, or absolute path
177
+ # Skip external links, anchors, absolute paths, and any URL with a
178
+ # scheme (colon present). Using a colon as the scheme sentinel is an
179
+ # intentional allowlist: bare filenames never contain colons, so any
180
+ # URL that does (http:, https:, javascript:, data:, file:, mailto:,
181
+ # blob:, vbscript:, etc.) is left untouched rather than resolved.
137
182
  if (link_url.startswith(('http://', 'https://', '//', '#', '/'))
138
- or (':' in link_url and not link_url.startswith('file:'))):
183
+ or ':' in link_url):
139
184
  return match.group(0)
140
185
 
141
186
  # Extract anchor if present (only relevant for links, not images)
@@ -148,45 +193,51 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
148
193
  if "/" not in link_url and "\\" not in link_url:
149
194
  # Track statistics
150
195
  if is_image:
151
- self.stats["images_processed"] += 1
196
+ stats["images_processed"] += 1
152
197
  else:
153
- self.stats["links_processed"] += 1
198
+ stats["links_processed"] += 1
154
199
 
155
200
  resolved_path = self._resolve_filename(link_url)
156
201
  if resolved_path:
157
202
  # Warn if this filename is ambiguous
158
- if self.config["warn_on_ambiguous"] and link_url in self.ambiguous_files:
203
+ if warn_on_ambiguous and link_url in self.ambiguous_files:
159
204
  all_paths = self.ambiguous_files[link_url]
160
205
  logger.warning(
161
- f"easylinks: Ambiguous filename '{link_url}' referred to in "
162
- f"'{page.file.src_path}': exists at {all_paths}. "
163
- f"Using '{resolved_path}'."
206
+ f"easylinks: Ambiguous filename '{_sanitize_log(link_url)}' referred to in "
207
+ f"'{_sanitize_log(page_src_path)}': exists at "
208
+ f"{[_sanitize_log(p) for p in all_paths]}. "
209
+ f"Using '{_sanitize_log(resolved_path)}'."
164
210
  )
165
211
 
166
212
  # Track successful resolution
167
213
  if is_image:
168
- self.stats["images_resolved"] += 1
214
+ stats["images_resolved"] += 1
169
215
  else:
170
- self.stats["links_resolved"] += 1
216
+ stats["links_resolved"] += 1
171
217
  # Count how many times each file is linked
172
218
  self.link_counts[resolved_path] += 1
173
219
 
174
- # Calculate relative path from current page to target
175
- relative_path = self._get_relative_path(page.file.src_path, resolved_path)
220
+ # Calculate relative path from current page to target; cache
221
+ # the result since from_path is constant for this page.
222
+ if resolved_path not in relative_path_cache:
223
+ relative_path_cache[resolved_path] = self._get_relative_path(
224
+ page_src_path, resolved_path
225
+ )
226
+ relative_path = relative_path_cache[resolved_path]
176
227
  # Reconstruct with or without the ! prefix
177
228
  prefix = "!" if is_image else ""
178
229
  return f"{prefix}[{link_text}]({relative_path}{anchor})"
179
230
  else:
180
231
  # Track unresolved links/images separately
181
232
  if is_image:
182
- self.stats["images_unresolved"] += 1
233
+ stats["images_unresolved"] += 1
183
234
  else:
184
- self.stats["links_unresolved"] += 1
235
+ stats["links_unresolved"] += 1
185
236
 
186
- if self.config["warn_on_missing"]:
237
+ if warn_on_missing:
187
238
  file_type = "image" if is_image else "file"
188
239
  logger.warning(
189
- f"easylinks: Could not resolve {file_type} link to '{link_url}' on page '{page.file.src_path}'"
240
+ f"easylinks: Could not resolve {file_type} link to '{_sanitize_log(link_url)}' on page '{_sanitize_log(page_src_path)}'"
190
241
  )
191
242
 
192
243
  return match.group(0)
@@ -229,9 +280,10 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
229
280
 
230
281
  def _restore_protected_blocks(self, markdown: str, protected_blocks: dict) -> str:
231
282
  """Restore protected blocks from placeholders."""
232
- for placeholder, original in protected_blocks.items():
233
- markdown = markdown.replace(placeholder, original)
234
- return markdown
283
+ if not protected_blocks:
284
+ return markdown
285
+ pattern = re.compile('|'.join(re.escape(k) for k in protected_blocks))
286
+ return pattern.sub(lambda m: protected_blocks[m.group(0)], markdown)
235
287
 
236
288
  def _resolve_filename(self, filename: str) -> Optional[str]:
237
289
  """Resolve a filename to its full path within the docs."""
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-easylinks-plugin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: An MkDocs plugin that allows linking to files by filename only
5
5
  Author: Daniel Ferguson
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dsferg/mkdocs-easylinks-plugin
8
8
  Project-URL: Documentation, https://github.com/dsferg/mkdocs-easylinks-plugin
9
9
  Project-URL: Repository, https://github.com/dsferg/mkdocs-easylinks-plugin
10
10
  Keywords: mkdocs,plugin,links,cross-references
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
@@ -1,14 +1,14 @@
1
1
  [build-system]
2
- requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
2
+ requires = ["setuptools>=77.0.0", "wheel", "setuptools_scm[toml]>=6.2"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mkdocs-easylinks-plugin"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "An MkDocs plugin that allows linking to files by filename only"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- license = {text = "MIT"}
11
+ license = "MIT"
12
12
  authors = [
13
13
  {name = "Daniel Ferguson"}
14
14
  ]
@@ -16,7 +16,6 @@ keywords = ["mkdocs", "plugin", "links", "cross-references"]
16
16
  classifiers = [
17
17
  "Development Status :: 4 - Beta",
18
18
  "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
19
  "Programming Language :: Python :: 3",
21
20
  "Programming Language :: Python :: 3.10",
22
21
  "Programming Language :: Python :: 3.11",
@@ -1152,3 +1152,127 @@ Regular paragraph with [example link](example.md).
1152
1152
 
1153
1153
  # Image in admonition should be processed
1154
1154
  assert "../images/diagram.png" in result
1155
+
1156
+ # ------------------------------------------------------------------
1157
+ # Security fix: log injection (_sanitize_log)
1158
+ # ------------------------------------------------------------------
1159
+
1160
+ def test_sanitize_log_escapes_newlines(self):
1161
+ """_sanitize_log must escape newlines so they cannot inject fake log lines."""
1162
+ from mkdocs_easylinks.plugin import _sanitize_log
1163
+ assert _sanitize_log("file\nINJECTED WARNING.md") == "file\\nINJECTED WARNING.md"
1164
+
1165
+ def test_sanitize_log_escapes_carriage_returns(self):
1166
+ """_sanitize_log must escape carriage returns."""
1167
+ from mkdocs_easylinks.plugin import _sanitize_log
1168
+ assert _sanitize_log("file\r.md") == "file\\r.md"
1169
+
1170
+ def test_sanitize_log_escapes_tabs(self):
1171
+ """_sanitize_log must escape tab characters."""
1172
+ from mkdocs_easylinks.plugin import _sanitize_log
1173
+ assert _sanitize_log("file\t.md") == "file\\t.md"
1174
+
1175
+ def test_sanitize_log_normal_string_unchanged(self):
1176
+ """_sanitize_log must leave strings without control characters unchanged."""
1177
+ from mkdocs_easylinks.plugin import _sanitize_log
1178
+ assert _sanitize_log("subdir/normal-file.md") == "subdir/normal-file.md"
1179
+
1180
+ def test_sanitize_log_multiple_control_chars(self):
1181
+ """_sanitize_log must escape all control character types in one pass."""
1182
+ from mkdocs_easylinks.plugin import _sanitize_log
1183
+ assert _sanitize_log("a\nb\rc\td") == "a\\nb\\rc\\td"
1184
+
1185
+ # ------------------------------------------------------------------
1186
+ # Security fix: path traversal (_is_safe_path / on_files)
1187
+ # ------------------------------------------------------------------
1188
+
1189
+ def test_is_safe_path_normal_path(self):
1190
+ """A normal relative path should be considered safe."""
1191
+ assert self.plugin._is_safe_path("subdir/file.md") is True
1192
+
1193
+ def test_is_safe_path_root_level_file(self):
1194
+ """A filename at the root level should be safe."""
1195
+ assert self.plugin._is_safe_path("file.md") is True
1196
+
1197
+ def test_is_safe_path_traversal_escape(self):
1198
+ """A path that escapes the docs root must be rejected."""
1199
+ assert self.plugin._is_safe_path("../../etc/passwd") is False
1200
+
1201
+ def test_is_safe_path_single_parent_traversal(self):
1202
+ """A path starting with .. must be rejected."""
1203
+ assert self.plugin._is_safe_path("../outside.md") is False
1204
+
1205
+ def test_is_safe_path_embedded_traversal_that_escapes(self):
1206
+ """An embedded .. sequence that resolves outside the root must be rejected."""
1207
+ assert self.plugin._is_safe_path("subdir/../../../escape.md") is False
1208
+
1209
+ def test_is_safe_path_embedded_traversal_that_stays_inside(self):
1210
+ """An embedded .. that stays within the root should be accepted."""
1211
+ assert self.plugin._is_safe_path("subdir/../other/file.md") is True
1212
+
1213
+ def test_traversal_path_ignored_in_on_files(self):
1214
+ """Files whose src_path escapes the docs root must be excluded from the index."""
1215
+ mock_config = MagicMock()
1216
+ mock_files = MagicMock(spec=Files)
1217
+
1218
+ safe_file = self.create_mock_file("docs/safe.md")
1219
+ traversal_file = self.create_mock_file("../../secret.md")
1220
+
1221
+ mock_files.__iter__ = MagicMock(return_value=iter([safe_file, traversal_file]))
1222
+
1223
+ self.plugin.on_files(mock_files, config=mock_config)
1224
+
1225
+ assert "safe.md" in self.plugin.file_map
1226
+ assert "secret.md" not in self.plugin.file_map
1227
+ assert self.plugin.stats["files_ignored"] == 1
1228
+
1229
+ # ------------------------------------------------------------------
1230
+ # Security fix: URL scheme allowlist
1231
+ # ------------------------------------------------------------------
1232
+
1233
+ def test_dangerous_url_schemes_unchanged(self):
1234
+ """Links with dangerous URL schemes must be returned untouched."""
1235
+ page = self.create_mock_page("docs/index.md")
1236
+
1237
+ dangerous = [
1238
+ "[XSS](javascript:alert(1))",
1239
+ "[Data](data:text/html,<h1>test</h1>)",
1240
+ "[VB](vbscript:msgbox('xss'))",
1241
+ "[Blob](blob:https://example.com/some-uuid)",
1242
+ ]
1243
+
1244
+ for markdown in dangerous:
1245
+ result = self.plugin._process_links(markdown, page)
1246
+ assert result == markdown, f"Expected unchanged: {markdown}"
1247
+
1248
+ def test_file_url_unchanged(self):
1249
+ """file: URLs must be passed through unchanged, not treated as filenames."""
1250
+ # Even if a matching basename happened to be indexed, the scheme must win.
1251
+ self.plugin.file_map = {"passwd": "/etc/passwd"}
1252
+ page = self.create_mock_page("docs/index.md")
1253
+ markdown = "[Secret](file:///etc/passwd)"
1254
+
1255
+ result = self.plugin._process_links(markdown, page)
1256
+ assert result == markdown
1257
+
1258
+ # ------------------------------------------------------------------
1259
+ # Security fix: empty string in exclude_dirs
1260
+ # ------------------------------------------------------------------
1261
+
1262
+ def test_exclude_dirs_empty_string_does_not_exclude_all(self):
1263
+ """An empty string entry in exclude_dirs must not silently exclude every file."""
1264
+ self.plugin.config["exclude_dirs"] = [""]
1265
+
1266
+ mock_config = MagicMock()
1267
+ mock_files = MagicMock(spec=Files)
1268
+
1269
+ file1 = self.create_mock_file("docs/page.md")
1270
+ file2 = self.create_mock_file("docs/another.md")
1271
+
1272
+ mock_files.__iter__ = MagicMock(return_value=iter([file1, file2]))
1273
+
1274
+ self.plugin.on_files(mock_files, config=mock_config)
1275
+
1276
+ assert "page.md" in self.plugin.file_map
1277
+ assert "another.md" in self.plugin.file_map
1278
+ assert self.plugin.stats["files_indexed"] == 2