From a84f7009acd4c51642e10a2cd15cea415860c0a7 Mon Sep 17 00:00:00 2001 From: James Addison <55152140+jayaddison@users.noreply.github.com> Date: Fri, 4 Oct 2024 09:48:47 +0000 Subject: [PATCH] Restrict substitution of ``copyright`` years (#12516) Only substitute copyright notice years with values from ``SOURCE_DATE_EPOCH`` for entries that match the current system clock year, and disallow substitution of future years. Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com> --- CHANGES.rst | 4 ++ sphinx/config.py | 32 ++++++++++---- .../test_build_html_copyright.py | 39 +++++++++-------- tests/test_config/test_correct_year.py | 42 ++++++++++--------- 4 files changed, 72 insertions(+), 45 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c1d0f410a..55fb2cf8f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -110,6 +110,10 @@ Bugs fixed * #12916: Restore support for custom templates named with the legacy ``_t`` suffix during ``apidoc`` RST rendering (regression in 7.4.0). Patch by James Addison. +* #12451: Only substitute copyright notice years with values from + ``SOURCE_DATE_EPOCH`` for entries that match the current system clock year, + and disallow substitution of future years. + Patch by James Addison and Adam Turner. Testing ------- diff --git a/sphinx/config.py b/sphinx/config.py index 3096cef03..5654eb255 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -625,22 +625,34 @@ def correct_copyright_year(_app: Sphinx, config: Config) -> None: See https://reproducible-builds.org/specs/source-date-epoch/ """ - if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is None: + if source_date_epoch := int(getenv('SOURCE_DATE_EPOCH', '0')): + source_date_epoch_year = time.gmtime(source_date_epoch).tm_year + else: return - source_date_epoch_year = str(time.gmtime(int(source_date_epoch)).tm_year) + # If the current year is the replacement year, there's no work to do. + # We also skip replacement years that are in the future. + current_year = time.localtime().tm_year + if current_year <= source_date_epoch_year: + return + current_yr = str(current_year) + replace_yr = str(source_date_epoch_year) for k in ('copyright', 'epub_copyright'): if k in config: value: str | Sequence[str] = config[k] if isinstance(value, str): - config[k] = _substitute_copyright_year(value, source_date_epoch_year) + config[k] = _substitute_copyright_year(value, current_yr, replace_yr) else: - items = (_substitute_copyright_year(x, source_date_epoch_year) for x in value) + items = ( + _substitute_copyright_year(x, current_yr, replace_yr) for x in value + ) config[k] = type(value)(items) # type: ignore[call-arg] -def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str: +def _substitute_copyright_year( + copyright_line: str, current_year: str, replace_year: str +) -> str: """Replace the year in a single copyright line. Legal formats are: @@ -657,13 +669,17 @@ def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str: if len(copyright_line) < 4 or not copyright_line[:4].isdigit(): return copyright_line - if copyright_line[4:5] in {'', ' ', ','}: + if copyright_line[:4] == current_year and copyright_line[4:5] in {'', ' ', ','}: return replace_year + copyright_line[4:] - if copyright_line[4] != '-': + if copyright_line[4:5] != '-': return copyright_line - if copyright_line[5:9].isdigit() and copyright_line[9:10] in {'', ' ', ','}: + if ( + copyright_line[5:9].isdigit() + and copyright_line[5:9] == current_year + and copyright_line[9:10] in {'', ' ', ','} + ): return copyright_line[:5] + replace_year + copyright_line[9:] return copyright_line diff --git a/tests/test_builders/test_build_html_copyright.py b/tests/test_builders/test_build_html_copyright.py index 2ddc50532..8e017ede4 100644 --- a/tests/test_builders/test_build_html_copyright.py +++ b/tests/test_builders/test_build_html_copyright.py @@ -2,16 +2,21 @@ import time import pytest +LT = time.localtime() +LT_NEW = (2009, *LT[1:], LT.tm_zone, LT.tm_gmtoff) +LOCALTIME_2009 = type(LT)(LT_NEW) + @pytest.fixture( params=[ - 1293840000, # 2011-01-01 00:00:00 - 1293839999, # 2010-12-31 23:59:59 + 1199145600, # 2008-01-01 00:00:00 + 1199145599, # 2007-12-31 23:59:59 ] ) def source_date_year(request, monkeypatch): source_date_epoch = request.param with monkeypatch.context() as m: + m.setattr(time, 'localtime', lambda *a: LOCALTIME_2009) m.setenv('SOURCE_DATE_EPOCH', str(source_date_epoch)) yield time.gmtime(source_date_epoch).tm_year @@ -53,24 +58,24 @@ def test_html_multi_line_copyright_sde(source_date_year, app): content = (app.outdir / 'index.html').read_text(encoding='utf-8') # check the copyright footer line by line (empty lines ignored) - assert f' © Copyright {source_date_year}.
\n' in content + assert ' © Copyright 2006.
\n' in content assert f' © Copyright 2006-{source_date_year}, Alice.
\n' in content - assert f' © Copyright 2010-{source_date_year}, Bob.
\n' in content - assert f' © Copyright 2014-{source_date_year}, Charlie.
\n' in content - assert f' © Copyright 2018-{source_date_year}, David.
\n' in content - assert f' © Copyright 2022-{source_date_year}, Eve.' in content + assert ' © Copyright 2010-2013, Bob.
\n' in content + assert ' © Copyright 2014-2017, Charlie.
\n' in content + assert ' © Copyright 2018-2021, David.
\n' in content + assert ' © Copyright 2022-2025, Eve.' in content # check the raw copyright footer block (empty lines included) assert ( - f' © Copyright {source_date_year}.
\n' - f' \n' + ' © Copyright 2006.
\n' + ' \n' f' © Copyright 2006-{source_date_year}, Alice.
\n' - f' \n' - f' © Copyright 2010-{source_date_year}, Bob.
\n' - f' \n' - f' © Copyright 2014-{source_date_year}, Charlie.
\n' - f' \n' - f' © Copyright 2018-{source_date_year}, David.
\n' - f' \n' - f' © Copyright 2022-{source_date_year}, Eve.' + ' \n' + ' © Copyright 2010-2013, Bob.
\n' + ' \n' + ' © Copyright 2014-2017, Charlie.
\n' + ' \n' + ' © Copyright 2018-2021, David.
\n' + ' \n' + ' © Copyright 2022-2025, Eve.' ) in content diff --git a/tests/test_config/test_correct_year.py b/tests/test_config/test_correct_year.py index 4d0c70f32..7aafe5a66 100644 --- a/tests/test_config/test_correct_year.py +++ b/tests/test_config/test_correct_year.py @@ -14,12 +14,13 @@ LOCALTIME_2009 = type(LT)(LT_NEW) @pytest.fixture( params=[ # test with SOURCE_DATE_EPOCH unset: no modification - (None, ''), - # test with SOURCE_DATE_EPOCH set: copyright year should be updated - ('1293840000', '2011'), - ('1293839999', '2010'), - ('1199145600', '2008'), - ('1199145599', '2007'), + (None, None), + # test with post-2009 SOURCE_DATE_EPOCH set: copyright year should not be updated + ('1293840000', 2011), + ('1293839999', 2010), + # test with pre-2009 SOURCE_DATE_EPOCH set: copyright year should be updated + ('1199145600', 2008), + ('1199145599', 2007), ], ) def expect_date(request, monkeypatch): @@ -39,7 +40,7 @@ def test_correct_year(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'2006-{expect_date}, Alice' else: assert cfg.copyright == copyright_date @@ -51,7 +52,7 @@ def test_correct_year_space(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'2006-{expect_date} Alice' else: assert cfg.copyright == copyright_date @@ -63,7 +64,7 @@ def test_correct_year_no_author(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'2006-{expect_date}' else: assert cfg.copyright == copyright_date @@ -75,7 +76,7 @@ def test_correct_year_single(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'{expect_date}, Alice' else: assert cfg.copyright == copyright_date @@ -87,7 +88,7 @@ def test_correct_year_single_space(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'{expect_date} Alice' else: assert cfg.copyright == copyright_date @@ -99,7 +100,7 @@ def test_correct_year_single_no_author(expect_date): cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'{expect_date}' else: assert cfg.copyright == copyright_date @@ -108,7 +109,7 @@ def test_correct_year_single_no_author(expect_date): def test_correct_year_multi_line(expect_date): # test that copyright is substituted copyright_dates = ( - '2006', + '2009', '2006-2009, Alice', '2010-2013, Bob', '2014-2017, Charlie', @@ -118,14 +119,15 @@ def test_correct_year_multi_line(expect_date): cfg = Config({'copyright': copyright_dates}, {}) assert cfg.copyright == copyright_dates correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == ( f'{expect_date}', f'2006-{expect_date}, Alice', - f'2010-{expect_date}, Bob', - f'2014-{expect_date}, Charlie', - f'2018-{expect_date}, David', - f'2022-{expect_date}, Eve', + # post 2009-dates aren't substituted + '2010-2013, Bob', + '2014-2017, Charlie', + '2018-2021, David', + '2022-2025, Eve', ) else: assert cfg.copyright == copyright_dates @@ -144,7 +146,7 @@ def test_correct_year_multi_line_all_formats(expect_date): cfg = Config({'copyright': copyright_dates}, {}) assert cfg.copyright == copyright_dates correct_copyright_year(None, cfg) # type: ignore[arg-type] - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == ( f'{expect_date}', f'{expect_date} Alice', @@ -166,7 +168,7 @@ def test_correct_year_app(expect_date, tmp_path, make_app): srcdir=tmp_path, confoverrides={'copyright': copyright_date}, ) - if expect_date: + if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert app.config.copyright == f'2006-{expect_date}, Alice' else: assert app.config.copyright == copyright_date