Skip to content

Commit 9949074

Browse files
authored
Fix prevent dangling symlink from crashing the build (#43)
* fix: prevent dangling symlink from crashing the build A dangling symlink in the docs directory would crash `mkdocs build` with an unhandled FileNotFoundError during file copy. `os.walk` lists the symlink as a regular file, but when `shutil.copyfile` (or `os.path.getmtime`) follows the symlink to a missing target, the uncaught OSError aborts the entire build. Fix: - `is_modified()`: catch OSError when calling getmtime on the source — return False so the file is skipped silently. - `copy_file()`: catch OSError around copy and log a warning, then skip the file gracefully instead of crashing. Closes: mkdocs/mkdocs#4048 * docs: add release note for dangling symlink crash fix * Apply suggestions from code review Co-authored-by: Xianpeng Shen <xianpeng.shen@gmail.com> * FIx trim trailing whitespace * fix: resolve log routing conflict between two dangling symlink fixes PR #3785 added early broken-symlink detection in utils.copy_file() that logged a warning and returned early. PR #4048 added an except OSError handler in files.py expecting the exception to propagate. Because the early return short-circuited before shutil.copyfile, the OSError handler was never reached, and the mkdocs.utils warning leaked to lastResort. Fix: raise FileNotFoundError in utils.copy_file() instead of logging, allowing the existing except OSError handler in files.py to catch it and log with the mkdocs.structure.files logger as expected.
1 parent b0a6962 commit 9949074

5 files changed

Lines changed: 41 additions & 11 deletions

File tree

docs/about/release-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The current members of the MkDocs-NG team.
4343
* Fix `validation.links.not_found` is always reported as INFO for excluded pages. #32
4444
* Fix `mkdocs serve` cleanup so temporary site directories are removed when the process receives `SIGTERM`. #36
4545
* Fix mkdocs theme color mode switching when `highlightjs` is disabled. #39
46+
* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #43
4647

4748
### Maintenance
4849

mkdocs/structure/files.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ def copy_file(self, dirty: bool = False) -> None:
514514
utils.copy_file(self.abs_src_path, output_path)
515515
except shutil.SameFileError:
516516
pass # Let plugins write directly into site_dir.
517+
except OSError as e:
518+
log.warning(f"Error copying '{self.src_uri}': {e}")
517519
elif isinstance(content, str):
518520
with open(output_path, "w", encoding="utf-8") as output_file:
519521
output_file.write(content)
@@ -526,9 +528,12 @@ def is_modified(self) -> bool:
526528
return True
527529
assert self.abs_src_path is not None
528530
if os.path.isfile(self.abs_dest_path):
529-
return os.path.getmtime(self.abs_dest_path) < os.path.getmtime(
530-
self.abs_src_path
531-
)
531+
try:
532+
return os.path.getmtime(self.abs_dest_path) < os.path.getmtime(
533+
self.abs_src_path
534+
)
535+
except OSError:
536+
return False
532537
return True
533538

534539
def is_documentation_page(self) -> bool:

mkdocs/tests/structure/file_tests.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,35 @@ def test_copy_file_from_content(self, dest_dir):
803803
with open(dest_path, encoding="utf-8") as f:
804804
self.assertEqual(f.read(), "ö")
805805

806+
@tempdir()
807+
@tempdir()
808+
def test_copy_file_dangling_symlink(self, src_dir, dest_dir):
809+
dangling = os.path.join(src_dir, "dangling.jpg")
810+
try:
811+
os.symlink(os.path.join(src_dir, "nonexistent"), dangling)
812+
except (OSError, NotImplementedError):
813+
self.skipTest("Creating symlinks not supported")
814+
815+
file = File("dangling.jpg", src_dir, dest_dir, use_directory_urls=False)
816+
with self.assertLogs("mkdocs.structure.files") as cm:
817+
file.copy_file()
818+
self.assertRegex(
819+
"\n".join(cm.output),
820+
r"^WARNING:mkdocs.structure.files:Error copying 'dangling.jpg'",
821+
)
822+
823+
@tempdir()
824+
@tempdir()
825+
def test_copy_file_missing_source(self, src_dir, dest_dir):
826+
"""File deleted between discovery and copy should warn, not crash."""
827+
file = File("missing.txt", src_dir, dest_dir, use_directory_urls=False)
828+
with self.assertLogs("mkdocs.structure.files") as cm:
829+
file.copy_file()
830+
self.assertRegex(
831+
"\n".join(cm.output),
832+
r"^WARNING:mkdocs.structure.files:Error copying 'missing.txt'",
833+
)
834+
806835
def test_files_append_remove_src_paths(self):
807836
fs = [
808837
File("index.md", "/path/to/docs", "/path/to/site", use_directory_urls=True),

mkdocs/tests/utils/utils_tests.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,15 +386,11 @@ def test_copy_files_without_permissions(self, src_dir, dst_dir):
386386
@tempdir()
387387
def test_copy_broken_symlink(self, src_dir, dst_dir):
388388
# Regression test for mkdocs/mkdocs#3785: dangling symlinks should
389-
# not crash the build. A warning should be logged and the file skipped.
389+
# not crash the build. A warning should be raised and the file skipped.
390390
broken_link = os.path.join(src_dir, "broken_link")
391391
os.symlink("/nonexistent/path", broken_link)
392-
with self.assertLogs("mkdocs", level="WARNING") as cm:
392+
with self.assertRaises(FileNotFoundError):
393393
utils.copy_file(broken_link, os.path.join(dst_dir, "broken_link"))
394-
self.assertTrue(
395-
any("Symlink broken" in m for m in cm.output),
396-
f"Expected a warning about broken symlink, got: {cm.output}",
397-
)
398394
# The broken symlink should not have been copied.
399395
self.assertFalse(os.path.exists(os.path.join(dst_dir, "broken_link")))
400396

mkdocs/utils/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,7 @@ def copy_file(source_path: str, output_path: str) -> None:
122122
if os.path.isdir(output_path):
123123
output_path = os.path.join(output_path, os.path.basename(source_path))
124124
if os.path.islink(source_path) and not os.path.exists(os.readlink(source_path)):
125-
log.warning(f"Symlink broken, could not copy file: {source_path}")
126-
return
125+
raise FileNotFoundError(f"Broken symlink: {source_path}")
127126
shutil.copyfile(source_path, output_path)
128127

129128

0 commit comments

Comments
 (0)