git-private2public 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.
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.2.2
4
+
5
+ - Fail closed with a concise redacted error when target-ref preflight times out.
6
+ - Prevent Git remote errors from echoing credential-bearing target URLs.
7
+
8
+ ## 0.2.1
9
+
10
+ - Add `.gitpublic/public/` overlays for public-only files after sanitization.
11
+ - Keep snapshot publications at exactly one root commit after applying overlays.
12
+ - Reject overlay symlinks, special files, reserved paths, and destination escapes.
13
+ - Permit tracked `.gitpublic/scan` policy when every active rule is generic `regex:`.
14
+
15
+ ## 0.2.0
16
+
17
+ - Add fail-closed scanning of every reachable blob, commit, and annotated tag.
18
+ - Apply built-in credential rules to every publish in addition to custom rules.
19
+ - Add one-commit `snapshot` mode for first public releases without private history.
20
+ - Require the public target to contain exactly the configured branches and tag set.
21
+ - Prevent pre-push publication from cloning a stale matching private remote.
22
+ - Remove all credential fragments from scan findings.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-private2public
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Like .gitignore, but for what goes public. Keep a sanitized public mirror of your private repo.
5
5
  Project-URL: Homepage, https://github.com/megamen32/git-private2public
6
6
  Project-URL: Repository, https://github.com/megamen32/git-private2public
@@ -16,7 +16,7 @@ private repo
16
16
  -> push to public repo (.gitpublic/config)
17
17
  ```
18
18
 
19
- Missing custom files mean “no custom rules for that step”. Built-in credential detection remains active for zero-config scans and guard mode.
19
+ Missing custom files mean “no custom rules for that step”. Built-in credential detection remains active for every publish, zero-config scan, and guard run.
20
20
 
21
21
  | File | If present | If missing or empty |
22
22
  |------|------------|---------------------|
@@ -26,6 +26,7 @@ Missing custom files mean “no custom rules for that step”. Built-in credenti
26
26
  | `.gitpublic/scan` | Adds custom blocking patterns | Built-in credential rules still protect zero-config scans and guard mode |
27
27
  | `.gitpublic/allow` | Exceptions for domain-like matches found by `scan` | No scan exceptions exist |
28
28
  | `.gitpublic/domains` | Same as `allow` alias | Nothing is allowlisted |
29
+ | `.gitpublic/public/` | Adds or replaces files in the sanitized public tree | No public-only overlay is applied |
29
30
 
30
31
  ## `.gitpublic/config`
31
32
 
@@ -83,6 +84,17 @@ What it does:
83
84
 
84
85
  It removes files from Git history in the temporary sanitized clone. It does not edit your private repo.
85
86
 
87
+ ## `.gitpublic/public/`
88
+
89
+ Files below this directory are copied into the sanitized tree after delete and
90
+ replace rules. Relative paths are preserved, so
91
+ `.gitpublic/public/README.md` becomes public `README.md`. Overlay files are
92
+ committed and pass the same mandatory secret scan as every other public file.
93
+
94
+ In `snapshot` mode the overlay is applied before genesis is constructed, so
95
+ the public output still contains exactly one root commit. Symlinks, special
96
+ files, `.git` paths, and destination path escapes are rejected.
97
+
86
98
  ## `.gitpublic/replace`
87
99
 
88
100
  One replacement per line:
@@ -113,6 +125,10 @@ regex:.*\.corp\.internal ==> example.com
113
125
 
114
126
  ## `.gitpublic/scan`
115
127
 
128
+ Generic scan policy may be tracked when every active rule starts with
129
+ `regex:`. Literal scan rules must remain untracked because the literal itself
130
+ may be private. `.gitpublic/replace` must always remain untracked.
131
+
116
132
  `.gitpublic/scan` adds custom blocking rules. Built-in credential rules are always available for zero-config scans and guard mode.
117
133
 
118
134
  One rule per line:
@@ -16,7 +16,7 @@
16
16
  -> push в публичный репозиторий (.gitpublic/config)
17
17
  ```
18
18
 
19
- Если какого-то файла нет, это значит “нет пользовательских правил для этого шага”. Встроенная проверка известных учётных данных всё равно работает в zero-config и guard-режимах.
19
+ Если какого-то файла нет, это значит “нет пользовательских правил для этого шага”. Встроенная проверка известных учётных данных работает в publish, zero-config и guard-режимах.
20
20
 
21
21
  | Файл | Если есть | Если файла нет или он пустой |
22
22
  |------|-----------|-------------------------------|
@@ -26,6 +26,7 @@
26
26
  | `.gitpublic/scan` | добавляет пользовательские блокирующие правила | встроенные правила продолжают работать в zero-config и guard-режимах |
27
27
  | `.gitpublic/allow` | исключения для доменов, найденных проверкой `scan` | исключений для проверки нет |
28
28
  | `.gitpublic/domains` | alias для `allow` | ничего не разрешается |
29
+ | `.gitpublic/public/` | добавляет или заменяет файлы в очищенном public-дереве | public-only overlay не применяется |
29
30
 
30
31
  ## `.gitpublic/config`
31
32
 
@@ -83,6 +84,17 @@ private/*.json
83
84
 
84
85
  Это удаляет файлы во временном очищенном clone. Приватный репозиторий не меняется.
85
86
 
87
+ ## `.gitpublic/public/`
88
+
89
+ Файлы из этой директории копируются в очищенное дерево после delete и
90
+ replace. Относительные пути сохраняются: `.gitpublic/public/README.md`
91
+ становится публичным `README.md`. Overlay-файлы попадают в public-коммит и
92
+ проходят ту же обязательную проверку секретов.
93
+
94
+ В `snapshot` overlay применяется до создания genesis, поэтом в публичном результате
95
+ по-прежнему ровно один root-коммит. Symlink, special-файлы, пути `.git` и выход
96
+ через destination path запрещены.
97
+
86
98
  ## `.gitpublic/replace`
87
99
 
88
100
  По одной замене на строку:
@@ -113,6 +125,10 @@ regex:.*\.corp\.internal ==> example.com
113
125
 
114
126
  ## `.gitpublic/scan`
115
127
 
128
+ Общую scan-политику можно отслеживать в Git, если каждое активное правило
129
+ начинается с `regex:`. Literal scan-правила должны оставаться untracked, потому что
130
+ сам literal может быть приватным. `.gitpublic/replace` всегда должен оставаться untracked.
131
+
116
132
  `.gitpublic/scan` добавляет свои блокирующие правила. Встроенные правила известных секретов доступны без настройки в zero-config и guard-режимах.
117
133
 
118
134
  По одному правилу на строку:
@@ -26,6 +26,7 @@ import json
26
26
  import os
27
27
  import re
28
28
  import shutil
29
+ import stat
29
30
  import subprocess
30
31
  import sys
31
32
  import tempfile
@@ -34,7 +35,7 @@ from pathlib import Path
34
35
  import time
35
36
  from typing import Iterable
36
37
 
37
- __version__ = "0.2.0"
38
+ __version__ = "0.2.2"
38
39
 
39
40
  try:
40
41
  import yaml
@@ -343,18 +344,31 @@ def clone_source(source: str, dest: Path) -> None:
343
344
  # filter-repo works on bare clones too; keep it simple.
344
345
 
345
346
 
346
- # Files inside .gitpublic/ that may carry literal secrets in their content
347
- # (replace patterns or scan rules) and therefore must never be committed.
348
- GITPUBLIC_SECRET_FILES = ("replace", "scan")
347
+ # Replacement rules may contain private literals and are never safe to track.
348
+ GITPUBLIC_REPLACE_FILE = ".gitpublic/replace"
349
+
350
+
351
+ def _is_generic_regex_rule(rule: str) -> bool:
352
+ """Return true for a valid regex containing real pattern syntax."""
353
+ if not rule.startswith("regex:"):
354
+ return False
355
+ expression = rule[len("regex:"):]
356
+ try:
357
+ re.compile(expression)
358
+ except re.error:
359
+ return False
360
+ return bool(
361
+ re.search(r"(?<!\\)(?:\[|\(|\{|\.|\*|\+|\?|\|)|\\[dDsSwW]", expression)
362
+ )
349
363
 
350
364
 
351
365
  def check_local_gitpublic_secrets_not_tracked(cwd: str) -> list[str]:
352
- """Refuse to publish if .gitpublic/{replace,scan} is tracked in the local repo.
366
+ """Return tracked policy files that can reveal literal private values.
353
367
 
354
- Only ``replace`` and ``scan`` may contain literal secrets (e.g.
355
- ``whisper.bezrabotnyi.com==>example.com`` or ``XYZ123`` as a scan
356
- pattern). The other files in .gitpublic/ — ``config``, ``ignore``,
357
- ``allow`` — are safe to commit.
368
+ ``replace`` is always rejected. A tracked ``scan`` is allowed only when
369
+ every active rule is explicitly ``regex:``; comments and blank lines are
370
+ ignored. This permits reusable provider signatures without allowing a
371
+ literal credential to become part of private Git history.
358
372
 
359
373
  Returns the list of tracked secret-bearing files. Empty list = OK.
360
374
  """
@@ -365,8 +379,26 @@ def check_local_gitpublic_secrets_not_tracked(cwd: str) -> list[str]:
365
379
  if res.returncode != 0:
366
380
  return []
367
381
  tracked = {f for f in res.stdout.splitlines() if f}
368
- secret_files = {f".gitpublic/{name}" for name in GITPUBLIC_SECRET_FILES}
369
- return sorted(tracked & secret_files)
382
+ unsafe: list[str] = []
383
+ if GITPUBLIC_REPLACE_FILE in tracked:
384
+ unsafe.append(GITPUBLIC_REPLACE_FILE)
385
+ scan_path = ".gitpublic/scan"
386
+ if scan_path in tracked:
387
+ indexed = subprocess.run(
388
+ ["git", "-C", cwd, "show", f":{scan_path}"],
389
+ capture_output=True,
390
+ text=True,
391
+ )
392
+ if indexed.returncode != 0:
393
+ unsafe.append(scan_path)
394
+ else:
395
+ rules = [
396
+ line.strip() for line in indexed.stdout.splitlines()
397
+ if line.strip() and not line.strip().startswith("#")
398
+ ]
399
+ if any(not _is_generic_regex_rule(rule) for rule in rules):
400
+ unsafe.append(scan_path)
401
+ return sorted(unsafe)
370
402
 
371
403
 
372
404
  def load_scan_rules(cwd: str, include_defaults: bool = False) -> list[str]:
@@ -567,24 +599,67 @@ def resolve_publish_source(configured_source: str, cwd: Path) -> str:
567
599
  return expand_github_shorthand(configured_source)
568
600
 
569
601
 
570
- def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
571
- """Replace all history and refs with one neutral commit of the current tree.
602
+ class OverlayError(RuntimeError):
603
+ """Raised when a public overlay cannot be copied without escaping policy."""
572
604
 
573
- Args:
574
- repo: Normal working clone whose checked-out tree becomes the snapshot.
575
- include_source_sha: Add the private source commit ID to the public message.
576
605
 
577
- Returns:
578
- The new root commit ID.
606
+ def _assert_within_worktree(path: Path, worktree: Path) -> None:
607
+ """Reject a destination whose resolved parent escapes the worktree."""
608
+ try:
609
+ path.resolve(strict=False).relative_to(worktree.resolve())
610
+ except ValueError as exc:
611
+ raise OverlayError("overlay path escapes public worktree") from exc
579
612
 
580
- Raises:
581
- subprocess.CalledProcessError: If Git cannot construct the snapshot.
613
+
614
+ def apply_public_overlay(overlay: Path, worktree: Path) -> list[str]:
615
+ """Copy safe regular overlay files into the sanitized worktree.
616
+
617
+ Symlinks, special files, Git metadata paths, and destination symlink chains
618
+ are rejected so a private checkout cannot redirect writes outside the clone.
582
619
  """
583
- source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(repo)).stdout.strip()
584
- tree = run(["git", "rev-parse", "HEAD^{tree}"], cwd=str(repo)).stdout.strip()
585
- message = "Public source snapshot"
586
- if include_source_sha:
587
- message += f"\n\nSource-Commit: {source_sha}"
620
+ if overlay.is_symlink():
621
+ raise OverlayError("public overlay root must not be a symlink")
622
+ if not overlay.exists():
623
+ return []
624
+ if not overlay.is_dir():
625
+ raise OverlayError("public overlay root must be a real directory")
626
+
627
+ copied: list[str] = []
628
+ for root_text, directories, files in os.walk(overlay, followlinks=False):
629
+ root = Path(root_text)
630
+ for directory in directories:
631
+ entry = root / directory
632
+ mode = entry.lstat().st_mode
633
+ if stat.S_ISLNK(mode):
634
+ raise OverlayError("public overlay contains a symlink")
635
+ if not stat.S_ISDIR(mode):
636
+ raise OverlayError("public overlay contains a non-regular directory entry")
637
+ for filename in files:
638
+ source = root / filename
639
+ relative = source.relative_to(overlay)
640
+ if not relative.parts or relative.parts[0] == ".git" or ".." in relative.parts:
641
+ raise OverlayError("public overlay path is reserved or escapes its root")
642
+ mode = source.lstat().st_mode
643
+ if stat.S_ISLNK(mode):
644
+ raise OverlayError("public overlay contains a symlink")
645
+ if not stat.S_ISREG(mode):
646
+ raise OverlayError("public overlay contains a non-regular file")
647
+
648
+ destination = worktree / relative
649
+ current = worktree
650
+ for part in relative.parts:
651
+ current = current / part
652
+ if current.is_symlink():
653
+ raise OverlayError("public overlay destination symlink would escape worktree")
654
+ _assert_within_worktree(destination.parent, worktree)
655
+ destination.parent.mkdir(parents=True, exist_ok=True)
656
+ shutil.copy2(source, destination)
657
+ copied.append(relative.as_posix())
658
+ return copied
659
+
660
+
661
+ def _neutral_commit_environment() -> dict[str, str]:
662
+ """Return deterministic, non-private author metadata for generated commits."""
588
663
  env = os.environ.copy()
589
664
  env.update(
590
665
  {
@@ -596,6 +671,52 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
596
671
  "GIT_COMMITTER_DATE": "2000-01-01T00:00:00Z",
597
672
  }
598
673
  )
674
+ return env
675
+
676
+
677
+ def commit_public_overlay(repo: Path) -> bool:
678
+ """Commit staged overlay changes with neutral metadata when changes exist."""
679
+ run(["git", "add", "-A"], cwd=str(repo))
680
+ diff = subprocess.run(
681
+ ["git", "diff", "--cached", "--quiet"], cwd=str(repo), capture_output=True
682
+ )
683
+ if diff.returncode == 0:
684
+ return False
685
+ if diff.returncode != 1:
686
+ raise OverlayError("cannot inspect staged public overlay")
687
+ subprocess.run(
688
+ ["git", "commit", "-m", "Apply public overlay"],
689
+ cwd=str(repo), capture_output=True, text=True, check=True,
690
+ env=_neutral_commit_environment(),
691
+ )
692
+ return True
693
+
694
+
695
+ def create_snapshot_root(
696
+ repo: Path,
697
+ include_source_sha: bool,
698
+ source_sha: str | None = None,
699
+ ) -> str:
700
+ """Replace all history and refs with one neutral commit of the current tree.
701
+
702
+ Args:
703
+ repo: Normal working clone whose checked-out tree becomes the snapshot.
704
+ include_source_sha: Add the private source commit ID to the public message.
705
+ source_sha: Original source ID captured before filtering, when available.
706
+
707
+ Returns:
708
+ The new root commit ID.
709
+
710
+ Raises:
711
+ subprocess.CalledProcessError: If Git cannot construct the snapshot.
712
+ """
713
+ if source_sha is None:
714
+ source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(repo)).stdout.strip()
715
+ run(["git", "add", "-A"], cwd=str(repo))
716
+ tree = run(["git", "write-tree"], cwd=str(repo)).stdout.strip()
717
+ message = "Public source snapshot"
718
+ if include_source_sha:
719
+ message += f"\n\nSource-Commit: {source_sha}"
599
720
  created = subprocess.run(
600
721
  ["git", "commit-tree", tree],
601
722
  cwd=str(repo),
@@ -603,7 +724,7 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
603
724
  capture_output=True,
604
725
  text=True,
605
726
  check=True,
606
- env=env,
727
+ env=_neutral_commit_environment(),
607
728
  )
608
729
  commit = created.stdout.strip()
609
730
  snapshot_ref = "refs/heads/git-private2public-snapshot"
@@ -618,12 +739,14 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
618
739
 
619
740
 
620
741
  def _remote_ref_sha(url: str, ref: str) -> str:
621
- res = subprocess.run(
622
- ["git", "ls-remote", url, ref], capture_output=True, text=True, timeout=30
623
- )
742
+ try:
743
+ res = subprocess.run(
744
+ ["git", "ls-remote", url, ref], capture_output=True, text=True, timeout=30
745
+ )
746
+ except subprocess.TimeoutExpired:
747
+ raise SystemExit("cannot read target ref: operation timed out") from None
624
748
  if res.returncode != 0:
625
- err = (res.stderr or res.stdout).strip()
626
- raise SystemExit(f"cannot read target ref {ref}: {err}")
749
+ raise SystemExit("cannot read target ref")
627
750
  line = res.stdout.strip().splitlines()
628
751
  return line[0].split()[0] if line else ""
629
752
 
@@ -637,8 +760,8 @@ def _remote_refs(url: str) -> dict[str, str]:
637
760
  text=True,
638
761
  timeout=30,
639
762
  )
640
- except subprocess.TimeoutExpired as exc:
641
- raise SystemExit("cannot enumerate target refs: operation timed out") from exc
763
+ except subprocess.TimeoutExpired:
764
+ raise SystemExit("cannot enumerate target refs: operation timed out") from None
642
765
  if result.returncode != 0:
643
766
  raise SystemExit("cannot enumerate target refs")
644
767
  refs: dict[str, str] = {}
@@ -767,6 +890,8 @@ def _break_alternates(work_gitdir: Path) -> bool:
767
890
 
768
891
  def publish(config: Config, scan_only: bool = False, cwd: str | None = None, output_format: str = "text") -> int:
769
892
  cwd = cwd or os.getcwd()
893
+ source_root = find_git_root(Path(cwd)) or Path(cwd).resolve()
894
+ overlay_path = source_root / ".gitpublic" / "public"
770
895
 
771
896
  # Only .gitpublic/{replace,scan} may carry literal secrets; the rest
772
897
  # (config / ignore / allow) is safe to commit. Refuse early if those
@@ -774,19 +899,17 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
774
899
  tracked_secrets = check_local_gitpublic_secrets_not_tracked(cwd)
775
900
  if tracked_secrets:
776
901
  sys.stderr.write(
777
- "\u2717 secret-bearing .gitpublic/ files are tracked in your local repo "
902
+ "\u2717 unsafe .gitpublic policy is tracked in the local repo "
778
903
  "\u2014 refusing to publish.\n"
779
- " These files may hold literal secrets inside replace/scan rules;\n"
780
- " pushing them would leak them to the public repo.\n"
904
+ " replace rules must stay untracked; tracked scan rules must be valid generic regex:.\n"
781
905
  )
782
906
  for f in tracked_secrets:
783
907
  sys.stderr.write(f" {f}\n")
784
908
  sys.stderr.write(
785
- "\n Fix (keep config/ignore/allow public, hide only replace/scan):\n"
909
+ "\n Fix (keep generic regex scan policy tracked if useful):\n"
786
910
  " echo '.gitpublic/replace' >> .gitignore\n"
787
- " echo '.gitpublic/scan' >> .gitignore\n"
788
- " git rm --cached .gitpublic/replace .gitpublic/scan\n"
789
- " git commit -m 'untrack .gitpublic/{replace,scan}'\n"
911
+ " git rm --cached .gitpublic/replace\n"
912
+ " Convert scan literals to generic regex rules, or untrack .gitpublic/scan.\n"
790
913
  )
791
914
  return 1
792
915
 
@@ -819,6 +942,7 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
819
942
  restore_gitdir = backup_info[1] if backup_info else None
820
943
  print(f"▸ Cloning {mask_url(source_url)} ...", file=sys.stderr)
821
944
  run(["git", "clone", "--no-local", source_url, str(work)])
945
+ original_source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(work)).stdout.strip()
822
946
 
823
947
  # Break object-store alternates so filter-repo cannot corrupt source
824
948
  _break_alternates(work / ".git")
@@ -826,10 +950,6 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
826
950
  # Detach origin (filter-repo removes it anyway)
827
951
  run(["git", "remote", "remove", "origin"], cwd=str(work), check=False)
828
952
 
829
- if config.mode == "snapshot":
830
- print("▸ Creating one-commit public snapshot ...", file=sys.stderr)
831
- create_snapshot_root(work, config.snapshot_include_source_sha)
832
-
833
953
  # Write replace-text file
834
954
  replace_file = tmp_path / "replacements.txt"
835
955
  if replaces:
@@ -854,6 +974,24 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
854
974
  sys.stderr.write(res.stderr)
855
975
  sys.exit("git-filter-repo failed (rc=" + str(res.returncode) + ")" + bk + restore)
856
976
 
977
+ try:
978
+ overlay_files = apply_public_overlay(overlay_path, work)
979
+ except OverlayError as exc:
980
+ print(f"✗ Public overlay rejected: {exc}", file=sys.stderr)
981
+ return 1
982
+ if overlay_files:
983
+ print(f"▸ Applied public overlay ({len(overlay_files)} file(s)).", file=sys.stderr)
984
+
985
+ if config.mode == "snapshot":
986
+ print("▸ Creating one-commit public snapshot ...", file=sys.stderr)
987
+ create_snapshot_root(
988
+ work,
989
+ config.snapshot_include_source_sha,
990
+ source_sha=original_source_sha,
991
+ )
992
+ elif overlay_files:
993
+ commit_public_overlay(work)
994
+
857
995
  # Scan the complete result before any ref can reach the target. Built-in
858
996
  # credential patterns are mandatory and custom rules are additive.
859
997
  print("▸ Scanning result and all reachable refs ...", file=sys.stderr)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-private2public"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Like .gitignore, but for what goes public. Keep a sanitized public mirror of your private repo."
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
  import subprocess
3
3
  import json
4
+ import os
4
5
 
5
6
  import pytest
6
7
 
@@ -114,8 +115,8 @@ def test_replace_rule_strips_separator_whitespace():
114
115
 
115
116
 
116
117
  def test_version_constant_matches_package_version():
117
- assert g.__version__ == "0.2.0"
118
- assert 'version = "0.2.0"' in (Path(__file__).parent.parent / "pyproject.toml").read_text()
118
+ assert g.__version__ == "0.2.2"
119
+ assert 'version = "0.2.2"' in (Path(__file__).parent.parent / "pyproject.toml").read_text()
119
120
 
120
121
 
121
122
  def test_gitpublic_secret_check_finds_only_replace_and_scan(tmp_path: Path):
@@ -153,6 +154,46 @@ def test_gitpublic_secret_check_clean_when_only_safe_files_tracked(tmp_path: Pat
153
154
  assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == []
154
155
 
155
156
 
157
+ def test_gitpublic_secret_check_allows_tracked_generic_regex_scan(tmp_path: Path):
158
+ repo = tmp_path / "r"
159
+ repo.mkdir()
160
+ subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
161
+ gp = repo / ".gitpublic"
162
+ gp.mkdir()
163
+ (gp / "scan").write_text(
164
+ "# Generic provider signatures are safe policy\n"
165
+ "regex:ghp_[A-Za-z0-9]{30,}\n"
166
+ "regex:AKIA[0-9A-Z]{16}\n"
167
+ )
168
+ subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
169
+
170
+ assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == []
171
+
172
+
173
+ def test_gitpublic_secret_check_rejects_tracked_scan_with_literal_rule(tmp_path: Path):
174
+ repo = tmp_path / "r"
175
+ repo.mkdir()
176
+ subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
177
+ gp = repo / ".gitpublic"
178
+ gp.mkdir()
179
+ (gp / "scan").write_text("regex:generic-[0-9]+\nactual-private-value\n")
180
+ subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
181
+
182
+ assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == [".gitpublic/scan"]
183
+
184
+
185
+ def test_gitpublic_secret_check_rejects_regex_prefix_without_generic_expression(tmp_path: Path):
186
+ repo = tmp_path / "r"
187
+ repo.mkdir()
188
+ subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
189
+ gp = repo / ".gitpublic"
190
+ gp.mkdir()
191
+ (gp / "scan").write_text("regex:actual-private-value\n")
192
+ subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
193
+
194
+ assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == [".gitpublic/scan"]
195
+
196
+
156
197
  # ---- guard / default-pattern tests ----------------------------------------- #
157
198
 
158
199
  def test_load_scan_rules_includes_defaults_when_requested(tmp_path: Path):
@@ -421,6 +462,22 @@ def test_remote_ref_sha_reads_exact_sha(monkeypatch):
421
462
  assert g._remote_ref_sha("example", "refs/heads/main") == sha
422
463
 
423
464
 
465
+ def test_remote_ref_sha_timeout_fails_closed_without_url_leak(monkeypatch):
466
+ secret_url = "https://x-access-token:super-secret-token@example.test/private.git"
467
+
468
+ def timeout(*args, **kwargs):
469
+ raise subprocess.TimeoutExpired(args[0], timeout=30)
470
+
471
+ monkeypatch.setattr(g.subprocess, "run", timeout)
472
+ with pytest.raises(SystemExit) as error:
473
+ g._remote_ref_sha(secret_url, "refs/heads/main")
474
+
475
+ message = str(error.value)
476
+ assert message == "cannot read target ref: operation timed out"
477
+ assert secret_url not in message
478
+ assert "super-secret-token" not in message
479
+
480
+
424
481
  @pytest.mark.parametrize(
425
482
  "unexpected_ref",
426
483
  ["refs/heads/stale", "refs/tags/stale", "refs/notes/private"],
@@ -794,6 +851,96 @@ def test_publish_blocks_stale_target_ref_before_writing_configured_head(tmp_path
794
851
  assert refs == ["refs/heads/stale"]
795
852
 
796
853
 
854
+ def test_snapshot_overlay_replaces_private_readme_without_source_history(tmp_path: Path):
855
+ source = tmp_path / "private"
856
+ target = tmp_path / "public.git"
857
+ source.mkdir()
858
+ subprocess.run(["git", "init", "-q", str(source)], check=True)
859
+ subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
860
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "private@example.test"], check=True)
861
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "Private Person"], check=True)
862
+ (source / "README.md").write_text("private internal instructions\n")
863
+ subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
864
+ subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "private history"], check=True)
865
+ overlay = source / ".gitpublic" / "public"
866
+ overlay.mkdir(parents=True)
867
+ (overlay / "README.md").write_text("# Public project\n")
868
+ cfg = g.Config(source=str(source), target=str(target), delete=["README.md"], mode="snapshot")
869
+
870
+ assert g.publish(cfg, cwd=str(source)) == 0
871
+
872
+ public_readme = subprocess.run(
873
+ ["git", "--git-dir", str(target), "show", "refs/heads/main:README.md"],
874
+ capture_output=True, text=True, check=True,
875
+ ).stdout
876
+ count = subprocess.run(
877
+ ["git", "--git-dir", str(target), "rev-list", "--count", "refs/heads/main"],
878
+ capture_output=True, text=True, check=True,
879
+ ).stdout.strip()
880
+ log = subprocess.run(
881
+ ["git", "--git-dir", str(target), "log", "--format=%an%n%ae%n%s", "refs/heads/main"],
882
+ capture_output=True, text=True, check=True,
883
+ ).stdout
884
+ assert public_readme == "# Public project\n"
885
+ assert count == "1"
886
+ assert "Private Person" not in log
887
+ assert "private history" not in log
888
+
889
+
890
+ def test_public_overlay_rejects_symlinks_and_non_regular_files(tmp_path: Path):
891
+ overlay = tmp_path / "overlay"
892
+ work = tmp_path / "work"
893
+ overlay.mkdir()
894
+ work.mkdir()
895
+ (overlay / "link").symlink_to(tmp_path / "outside")
896
+
897
+ with pytest.raises(g.OverlayError, match="symlink"):
898
+ g.apply_public_overlay(overlay, work)
899
+
900
+ (overlay / "link").unlink()
901
+ fifo = overlay / "pipe"
902
+ fifo.parent.mkdir(exist_ok=True)
903
+ os.mkfifo(fifo)
904
+ with pytest.raises(g.OverlayError, match="non-regular"):
905
+ g.apply_public_overlay(overlay, work)
906
+
907
+
908
+ def test_public_overlay_rejects_destination_symlink_escape(tmp_path: Path):
909
+ overlay = tmp_path / "overlay"
910
+ work = tmp_path / "work"
911
+ outside = tmp_path / "outside"
912
+ (overlay / "nested").mkdir(parents=True)
913
+ (overlay / "nested" / "README.md").write_text("public\n")
914
+ work.mkdir()
915
+ outside.mkdir()
916
+ (work / "nested").symlink_to(outside, target_is_directory=True)
917
+
918
+ with pytest.raises(g.OverlayError, match="destination symlink"):
919
+ g.apply_public_overlay(overlay, work)
920
+
921
+ assert not (outside / "README.md").exists()
922
+
923
+
924
+ def test_overlay_content_is_scanned_before_push(tmp_path: Path):
925
+ source = tmp_path / "private"
926
+ target = tmp_path / "public.git"
927
+ source.mkdir()
928
+ subprocess.run(["git", "init", "-q", str(source)], check=True)
929
+ subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
930
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "[email protected]"], check=True)
931
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "Test"], check=True)
932
+ (source / "safe.txt").write_text("safe\n")
933
+ subprocess.run(["git", "-C", str(source), "add", "safe.txt"], check=True)
934
+ subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "safe"], check=True)
935
+ overlay = source / ".gitpublic" / "public"
936
+ overlay.mkdir(parents=True)
937
+ (overlay / "credential.txt").write_text("ghp_" + "A" * 36 + "\n")
938
+ cfg = g.Config(source=str(source), target=str(target), mode="snapshot")
939
+
940
+ assert g.publish(cfg, cwd=str(source)) == 1
941
+ assert subprocess.run(["git", "--git-dir", str(target), "show-ref"], capture_output=True).returncode == 1
942
+
943
+
797
944
  def test_doctor_reports_missing_tools_and_config(tmp_path: Path, monkeypatch, capsys):
798
945
  monkeypatch.chdir(tmp_path)
799
946
  monkeypatch.setattr(g.shutil, "which", lambda name: None)
@@ -1,10 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.2.0
4
-
5
- - Add fail-closed scanning of every reachable blob, commit, and annotated tag.
6
- - Apply built-in credential rules to every publish in addition to custom rules.
7
- - Add one-commit `snapshot` mode for first public releases without private history.
8
- - Require the public target to contain exactly the configured branches and tag set.
9
- - Prevent pre-push publication from cloning a stale matching private remote.
10
- - Remove all credential fragments from scan findings.