git-private2public 0.2.0__tar.gz → 0.2.1__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.
- {git_private2public-0.2.0 → git_private2public-0.2.1}/CHANGELOG.md +7 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/PKG-INFO +1 -1
- {git_private2public-0.2.0 → git_private2public-0.2.1}/docs/ADVANCED.md +17 -1
- {git_private2public-0.2.0 → git_private2public-0.2.1}/docs/ADVANCED.ru.md +17 -1
- {git_private2public-0.2.0 → git_private2public-0.2.1}/git_private2public.py +173 -37
- {git_private2public-0.2.0 → git_private2public-0.2.1}/pyproject.toml +1 -1
- {git_private2public-0.2.0 → git_private2public-0.2.1}/tests/test_git_private2public.py +133 -2
- {git_private2public-0.2.0 → git_private2public-0.2.1}/.github/workflows/ci.yml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/.github/workflows/release.yml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/.gitignore +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/LICENSE +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/README.md +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/README.ru.md +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/example.yaml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.1}/templates/publish.yml +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- Add `.gitpublic/public/` overlays for public-only files after sanitization.
|
|
6
|
+
- Keep snapshot publications at exactly one root commit after applying overlays.
|
|
7
|
+
- Reject overlay symlinks, special files, reserved paths, and destination escapes.
|
|
8
|
+
- Permit tracked `.gitpublic/scan` policy when every active rule is generic `regex:`.
|
|
9
|
+
|
|
3
10
|
## 0.2.0
|
|
4
11
|
|
|
5
12
|
- Add fail-closed scanning of every reachable blob, commit, and annotated tag.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-private2public
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
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
|
-
Если какого-то файла нет, это значит “нет пользовательских правил для этого шага”. Встроенная проверка известных учётных данных
|
|
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.
|
|
38
|
+
__version__ = "0.2.1"
|
|
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
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
"""
|
|
366
|
+
"""Return tracked policy files that can reveal literal private values.
|
|
353
367
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
571
|
-
"""
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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=
|
|
727
|
+
env=_neutral_commit_environment(),
|
|
607
728
|
)
|
|
608
729
|
commit = created.stdout.strip()
|
|
609
730
|
snapshot_ref = "refs/heads/git-private2public-snapshot"
|
|
@@ -767,6 +888,8 @@ def _break_alternates(work_gitdir: Path) -> bool:
|
|
|
767
888
|
|
|
768
889
|
def publish(config: Config, scan_only: bool = False, cwd: str | None = None, output_format: str = "text") -> int:
|
|
769
890
|
cwd = cwd or os.getcwd()
|
|
891
|
+
source_root = find_git_root(Path(cwd)) or Path(cwd).resolve()
|
|
892
|
+
overlay_path = source_root / ".gitpublic" / "public"
|
|
770
893
|
|
|
771
894
|
# Only .gitpublic/{replace,scan} may carry literal secrets; the rest
|
|
772
895
|
# (config / ignore / allow) is safe to commit. Refuse early if those
|
|
@@ -774,19 +897,17 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
774
897
|
tracked_secrets = check_local_gitpublic_secrets_not_tracked(cwd)
|
|
775
898
|
if tracked_secrets:
|
|
776
899
|
sys.stderr.write(
|
|
777
|
-
"\u2717
|
|
900
|
+
"\u2717 unsafe .gitpublic policy is tracked in the local repo "
|
|
778
901
|
"\u2014 refusing to publish.\n"
|
|
779
|
-
"
|
|
780
|
-
" pushing them would leak them to the public repo.\n"
|
|
902
|
+
" replace rules must stay untracked; tracked scan rules must be valid generic regex:.\n"
|
|
781
903
|
)
|
|
782
904
|
for f in tracked_secrets:
|
|
783
905
|
sys.stderr.write(f" {f}\n")
|
|
784
906
|
sys.stderr.write(
|
|
785
|
-
"\n Fix (keep
|
|
907
|
+
"\n Fix (keep generic regex scan policy tracked if useful):\n"
|
|
786
908
|
" echo '.gitpublic/replace' >> .gitignore\n"
|
|
787
|
-
"
|
|
788
|
-
"
|
|
789
|
-
" git commit -m 'untrack .gitpublic/{replace,scan}'\n"
|
|
909
|
+
" git rm --cached .gitpublic/replace\n"
|
|
910
|
+
" Convert scan literals to generic regex rules, or untrack .gitpublic/scan.\n"
|
|
790
911
|
)
|
|
791
912
|
return 1
|
|
792
913
|
|
|
@@ -819,6 +940,7 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
819
940
|
restore_gitdir = backup_info[1] if backup_info else None
|
|
820
941
|
print(f"▸ Cloning {mask_url(source_url)} ...", file=sys.stderr)
|
|
821
942
|
run(["git", "clone", "--no-local", source_url, str(work)])
|
|
943
|
+
original_source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(work)).stdout.strip()
|
|
822
944
|
|
|
823
945
|
# Break object-store alternates so filter-repo cannot corrupt source
|
|
824
946
|
_break_alternates(work / ".git")
|
|
@@ -826,10 +948,6 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
826
948
|
# Detach origin (filter-repo removes it anyway)
|
|
827
949
|
run(["git", "remote", "remove", "origin"], cwd=str(work), check=False)
|
|
828
950
|
|
|
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
951
|
# Write replace-text file
|
|
834
952
|
replace_file = tmp_path / "replacements.txt"
|
|
835
953
|
if replaces:
|
|
@@ -854,6 +972,24 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
854
972
|
sys.stderr.write(res.stderr)
|
|
855
973
|
sys.exit("git-filter-repo failed (rc=" + str(res.returncode) + ")" + bk + restore)
|
|
856
974
|
|
|
975
|
+
try:
|
|
976
|
+
overlay_files = apply_public_overlay(overlay_path, work)
|
|
977
|
+
except OverlayError as exc:
|
|
978
|
+
print(f"✗ Public overlay rejected: {exc}", file=sys.stderr)
|
|
979
|
+
return 1
|
|
980
|
+
if overlay_files:
|
|
981
|
+
print(f"▸ Applied public overlay ({len(overlay_files)} file(s)).", file=sys.stderr)
|
|
982
|
+
|
|
983
|
+
if config.mode == "snapshot":
|
|
984
|
+
print("▸ Creating one-commit public snapshot ...", file=sys.stderr)
|
|
985
|
+
create_snapshot_root(
|
|
986
|
+
work,
|
|
987
|
+
config.snapshot_include_source_sha,
|
|
988
|
+
source_sha=original_source_sha,
|
|
989
|
+
)
|
|
990
|
+
elif overlay_files:
|
|
991
|
+
commit_public_overlay(work)
|
|
992
|
+
|
|
857
993
|
# Scan the complete result before any ref can reach the target. Built-in
|
|
858
994
|
# credential patterns are mandatory and custom rules are additive.
|
|
859
995
|
print("▸ Scanning result and all reachable refs ...", file=sys.stderr)
|
|
@@ -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.
|
|
118
|
-
assert 'version = "0.2.
|
|
118
|
+
assert g.__version__ == "0.2.1"
|
|
119
|
+
assert 'version = "0.2.1"' 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):
|
|
@@ -794,6 +835,96 @@ def test_publish_blocks_stale_target_ref_before_writing_configured_head(tmp_path
|
|
|
794
835
|
assert refs == ["refs/heads/stale"]
|
|
795
836
|
|
|
796
837
|
|
|
838
|
+
def test_snapshot_overlay_replaces_private_readme_without_source_history(tmp_path: Path):
|
|
839
|
+
source = tmp_path / "private"
|
|
840
|
+
target = tmp_path / "public.git"
|
|
841
|
+
source.mkdir()
|
|
842
|
+
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
|
843
|
+
subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
|
|
844
|
+
subprocess.run(["git", "-C", str(source), "config", "user.email", "private@example.test"], check=True)
|
|
845
|
+
subprocess.run(["git", "-C", str(source), "config", "user.name", "Private Person"], check=True)
|
|
846
|
+
(source / "README.md").write_text("private internal instructions\n")
|
|
847
|
+
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
|
848
|
+
subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "private history"], check=True)
|
|
849
|
+
overlay = source / ".gitpublic" / "public"
|
|
850
|
+
overlay.mkdir(parents=True)
|
|
851
|
+
(overlay / "README.md").write_text("# Public project\n")
|
|
852
|
+
cfg = g.Config(source=str(source), target=str(target), delete=["README.md"], mode="snapshot")
|
|
853
|
+
|
|
854
|
+
assert g.publish(cfg, cwd=str(source)) == 0
|
|
855
|
+
|
|
856
|
+
public_readme = subprocess.run(
|
|
857
|
+
["git", "--git-dir", str(target), "show", "refs/heads/main:README.md"],
|
|
858
|
+
capture_output=True, text=True, check=True,
|
|
859
|
+
).stdout
|
|
860
|
+
count = subprocess.run(
|
|
861
|
+
["git", "--git-dir", str(target), "rev-list", "--count", "refs/heads/main"],
|
|
862
|
+
capture_output=True, text=True, check=True,
|
|
863
|
+
).stdout.strip()
|
|
864
|
+
log = subprocess.run(
|
|
865
|
+
["git", "--git-dir", str(target), "log", "--format=%an%n%ae%n%s", "refs/heads/main"],
|
|
866
|
+
capture_output=True, text=True, check=True,
|
|
867
|
+
).stdout
|
|
868
|
+
assert public_readme == "# Public project\n"
|
|
869
|
+
assert count == "1"
|
|
870
|
+
assert "Private Person" not in log
|
|
871
|
+
assert "private history" not in log
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def test_public_overlay_rejects_symlinks_and_non_regular_files(tmp_path: Path):
|
|
875
|
+
overlay = tmp_path / "overlay"
|
|
876
|
+
work = tmp_path / "work"
|
|
877
|
+
overlay.mkdir()
|
|
878
|
+
work.mkdir()
|
|
879
|
+
(overlay / "link").symlink_to(tmp_path / "outside")
|
|
880
|
+
|
|
881
|
+
with pytest.raises(g.OverlayError, match="symlink"):
|
|
882
|
+
g.apply_public_overlay(overlay, work)
|
|
883
|
+
|
|
884
|
+
(overlay / "link").unlink()
|
|
885
|
+
fifo = overlay / "pipe"
|
|
886
|
+
fifo.parent.mkdir(exist_ok=True)
|
|
887
|
+
os.mkfifo(fifo)
|
|
888
|
+
with pytest.raises(g.OverlayError, match="non-regular"):
|
|
889
|
+
g.apply_public_overlay(overlay, work)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def test_public_overlay_rejects_destination_symlink_escape(tmp_path: Path):
|
|
893
|
+
overlay = tmp_path / "overlay"
|
|
894
|
+
work = tmp_path / "work"
|
|
895
|
+
outside = tmp_path / "outside"
|
|
896
|
+
(overlay / "nested").mkdir(parents=True)
|
|
897
|
+
(overlay / "nested" / "README.md").write_text("public\n")
|
|
898
|
+
work.mkdir()
|
|
899
|
+
outside.mkdir()
|
|
900
|
+
(work / "nested").symlink_to(outside, target_is_directory=True)
|
|
901
|
+
|
|
902
|
+
with pytest.raises(g.OverlayError, match="destination symlink"):
|
|
903
|
+
g.apply_public_overlay(overlay, work)
|
|
904
|
+
|
|
905
|
+
assert not (outside / "README.md").exists()
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def test_overlay_content_is_scanned_before_push(tmp_path: Path):
|
|
909
|
+
source = tmp_path / "private"
|
|
910
|
+
target = tmp_path / "public.git"
|
|
911
|
+
source.mkdir()
|
|
912
|
+
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
|
913
|
+
subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
|
|
914
|
+
subprocess.run(["git", "-C", str(source), "config", "user.email", "[email protected]"], check=True)
|
|
915
|
+
subprocess.run(["git", "-C", str(source), "config", "user.name", "Test"], check=True)
|
|
916
|
+
(source / "safe.txt").write_text("safe\n")
|
|
917
|
+
subprocess.run(["git", "-C", str(source), "add", "safe.txt"], check=True)
|
|
918
|
+
subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "safe"], check=True)
|
|
919
|
+
overlay = source / ".gitpublic" / "public"
|
|
920
|
+
overlay.mkdir(parents=True)
|
|
921
|
+
(overlay / "credential.txt").write_text("ghp_" + "A" * 36 + "\n")
|
|
922
|
+
cfg = g.Config(source=str(source), target=str(target), mode="snapshot")
|
|
923
|
+
|
|
924
|
+
assert g.publish(cfg, cwd=str(source)) == 1
|
|
925
|
+
assert subprocess.run(["git", "--git-dir", str(target), "show-ref"], capture_output=True).returncode == 1
|
|
926
|
+
|
|
927
|
+
|
|
797
928
|
def test_doctor_reports_missing_tools_and_config(tmp_path: Path, monkeypatch, capsys):
|
|
798
929
|
monkeypatch.chdir(tmp_path)
|
|
799
930
|
monkeypatch.setattr(g.shutil, "which", lambda name: None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|