prj-forgejo-org-refactor/tools/inventory.py
tegwick 660c429838 Prepare org reference inventory and stewardship assignment review
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e782-9f45-7382-afff-77ad665b88ec
2026-09-28 12:42:22 +02:00

206 lines
10 KiB
Python

#!/usr/bin/env python3
"""Read-only org-reference inventory. Emits reference tokens, never source lines."""
import argparse
import concurrent.futures
import hashlib
import io
import zipfile
import json
import re
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
BASE = 'https://forgejo.coulomb.social'
# Capture only the owner and first path component; never URL credentials/query.
REF = re.compile(r'(?<![A-Za-z0-9_-])coulomb/([A-Za-z0-9_.-]+)')
SENSITIVE = re.compile(r'(^|/)(\.env($|\.)|id_rsa|id_ed25519)|\.(pem|key|p12|pfx|age)$|sops\.|\.sops\.', re.I)
BINARY = {'.png','.jpg','.jpeg','.gif','.pdf','.zip','.gz','.woff','.woff2','.ico','.mp4','.sqlite','.db'}
SELF = Path(__file__).resolve().parents[1]
def run(repo, *args):
r = subprocess.run(['git', '-C', str(repo), *args], capture_output=True)
if r.returncode:
raise RuntimeError('git ' + args[0] + ' failed')
return r.stdout
def get(url):
try:
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
raise RuntimeError('HTTP ' + str(e.code)) from None
def pages(path):
rows = []
seen = set()
for page in range(1, 1001):
batch = get(BASE + path + f'?limit=50&page={page}')
if not isinstance(batch, list):
raise RuntimeError('expected paginated list')
if not batch:
return rows
digest = hashlib.sha256(json.dumps(batch, sort_keys=True).encode()).hexdigest()
if digest in seen:
raise RuntimeError('pagination repeated a page')
seen.add(digest)
rows.extend(batch)
raise RuntimeError('pagination limit exceeded')
def categories(path):
p = path.lower()
kinds = []
for flag, name in [
('argocd' in p, 'gitops'),
(p.endswith('chart.yaml'), 'chart-metadata'),
('chart' in p or 'values' in p, 'chart-values'),
(p.endswith('.gitmodules'), 'submodule'),
(any(x in p for x in ['.gitea/','.forgejo/','.github/','workflow','jenkins','ci/']), 'ci'),
(any(x in p for x in ['retention','prune','live-image','release','rollback']), 'release-retention'),
(any(x in p for x in ['credential','policy','routing','access']), 'access-policy'),
(any(x in p for x in ['lock','requirements','pyproject','package.json','npmrc']), 'package-consumer'),
(any(x in p.split('/') for x in ['history','archived','evidence','workplans']), 'historical-or-planning'),
]:
if flag:
kinds.append(name)
return kinds or ['other']
def scan(path):
errors, exclusions, refs = [], [], []
start = run(path, 'rev-parse', 'HEAD').decode().strip()
remotes = []
raw = run(path, 'config', '--get-regexp', r'^remote\..*\.url$') if run(path,'remote').strip() else b''
for line in raw.decode(errors='replace').splitlines():
key, value = line.split(' ', 1)
match = REF.search(value)
remotes.append({'remote':key.split('.')[1], 'owner_path':match.group(0).removesuffix('.git') if match else None,
'transport':'https' if value.startswith('https:') else 'ssh-or-other'})
files = sorted(set(run(path, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split(b'\0')) - {b''})
h = hashlib.sha256()
for encoded in files:
name = encoded.decode(errors='replace')
file = path / name
if file.is_dir():
exclusions.append({'path':name,'reason':'gitlink-or-directory-not-scanned'}); continue
if file.is_symlink():
exclusions.append({'path':name,'reason':'symlink-not-followed'}); continue
if SENSITIVE.search(name):
exclusions.append({'path':name,'reason':'sensitive-or-encrypted'}); continue
if file.suffix.lower() in BINARY:
exclusions.append({'path':name,'reason':'binary-extension'}); continue
try:
if file.stat().st_size > 4_000_000:
exclusions.append({'path':name,'reason':'over-4MB'}); continue
data = file.read_bytes()
except OSError:
errors.append({'path':name,'reason':'unreadable-or-missing'}); continue
h.update(encoded + b'\0' + hashlib.sha256(data).digest())
if b'\0' in data:
exclusions.append({'path':name,'reason':'binary-content'}); continue
for number, line in enumerate(data.decode(errors='replace').splitlines(), 1):
for match in REF.finditer(line):
refs.append({'path':name,'line':number,'reference':match.group(0), 'categories':categories(name)})
end = run(path, 'rev-parse', 'HEAD').decode().strip()
if start != end:
errors.append({'reason':'HEAD-changed-during-scan'})
return {'name':path.name, 'path':str(path), 'head':start,
'content_sha256':h.hexdigest(), 'remotes':remotes, 'files_considered':len(files),
'references':refs,'exclusions':exclusions,'errors':errors}
def scan_archive(repo):
"""Read a public default-branch archive in memory, never extract to disk."""
name=repo['name']
branch=repo['default_branch']
from urllib.parse import quote
branch_info=get(BASE+'/api/v1/repos/coulomb/'+name+'/branches/'+quote(branch,safe=''))
commit=branch_info['commit']['id']
url=BASE+'/api/v1/repos/coulomb/'+name+'/archive/'+commit+'.zip'
with urllib.request.urlopen(url,timeout=30) as response:
raw=response.read(50_000_001)
if len(raw)>50_000_000:
raise RuntimeError('archive exceeds 50MB')
refs=[];exclusions=[]
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
for item in sorted(archive.infolist(),key=lambda i:i.filename):
if item.is_dir(): continue
path=item.filename.split('/',1)[-1]
if SENSITIVE.search(path) or Path(path).suffix.lower() in BINARY or item.file_size>4_000_000:
exclusions.append({'path':path,'reason':'sensitive-binary-or-large'});continue
data=archive.read(item)
if b'\0' in data:continue
for number,line in enumerate(data.decode(errors='replace').splitlines(),1):
for match in REF.finditer(line):
refs.append({'path':path,'line':number,'reference':match.group(0),'categories':categories(path)})
return {'name':name,'commit':commit,'references':refs,'exclusions':exclusions}
def collect(root):
gaps = [
'Anonymous forge enumeration cannot attest private repository/package completeness.',
'Team membership, deploy keys, webhooks, runners, protected branches and server-side access policies require an authorized owner export.',
'Local working trees and public remote-only default-branch archives are scanned; other branches and inaccessible repos are not content-scanned.',
'Hidden sibling directories, ignored files, submodule contents outside discovered repos, binary/large/encrypted files and symlinks are not scanned; exclusions are recorded.',
'Runtime image/source inventory is collected separately; source scan cannot attest all runtime or external consumers.',
'Dynamic/computed org or repository names require owner review; token scan finds literal coulomb/ references only.',
]
hub = get('http://127.0.0.1:8000/repos/')
repos = pages('/api/v1/orgs/coulomb/repos')
try:
packages = pages('/api/v1/packages/coulomb')
except (RuntimeError, OSError):
packages = []; gaps.append('Package API unavailable.')
paths = {p.resolve() for p in root.iterdir() if p.is_dir() and not p.name.startswith('.') and (p/'.git').exists()}
paths.update(Path(r['local_path']).resolve() for r in hub if r.get('local_path') and (Path(r['local_path'])/'.git').exists())
paths.discard(SELF)
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
scans = list(pool.map(scan, sorted(paths)))
server = [{k:r.get(k) for k in ['id','name','full_name','private','archived','default_branch','description']} for r in repos]
registered = [{k:r.get(k) for k in ['id','slug','local_path','status','description']} for r in hub]
local_names = {m['owner_path'] for s in scans for m in s['remotes'] if m['owner_path']}
public_names = {r['full_name'] for r in server}
remote_scans=[]
for repo in server:
if repo['full_name'] not in local_names and repo['name']!=SELF.name:
try: remote_scans.append(scan_archive(repo))
except (OSError,RuntimeError,ValueError): gaps.append('Remote archive unavailable: '+repo['full_name'])
return {'schema_version':1,'forge':{'base_url':BASE,'version':get(BASE+'/api/v1/version'),'authentication':'anonymous'},
'g1_complete':False, 'coverage_gaps':gaps,
'server_repositories':sorted(server,key=lambda r:r['id']),
'registered_repositories':sorted(registered,key=lambda r:r['slug']),
'packages': sorted([{'id':p.get('id'),'name':p.get('name'),'type':p.get('type'),'version':p.get('version'),
'owner':(p.get('owner') or {}).get('login'),
'linked_repository_id':(p.get('repository') or {}).get('id')} for p in packages],key=lambda p:(str(p['id']),str(p['version']))),
'reconciliation':{'server_without_local_origin':sorted(public_names-local_names-{f'coulomb/{SELF.name}'}),
'local_origin_not_publicly_listed':sorted(local_names-public_names),
'coordinator_excluded':str(SELF)},
'local_repositories':scans,'remote_archives':remote_scans}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--root',type=Path,default=Path('/home/worsch'))
ap.add_argument('--output',type=Path,required=True)
ap.add_argument('--compare',type=Path)
args=ap.parse_args()
result=collect(args.root)
encoded=json.dumps(result,sort_keys=True,indent=2)+'\n'
args.output.write_text(encoded)
print(json.dumps({'repos':len(result['server_repositories']), 'local_repos':len(result['local_repositories']),
'references':sum(len(r['references']) for r in result['local_repositories']),
'sha256':hashlib.sha256(encoded.encode()).hexdigest(),'g1_complete':False}))
if args.compare:
same=json.loads(args.compare.read_text())==result
print('normalized_equal='+str(same).lower())
return 0 if same else 2
return 0
if __name__=='__main__':
raise SystemExit(main())