diff --git a/README.md b/README.md index cacbd74..354424e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Select a checkpoint row and click **Compare checkpoint to current**. AppTrail ca Exclusions apply to hashing, metadata, and text capture. Enter one glob per line. Defaults skip `.git/`, `__pycache__/`, `.venv/`, and `node_modules/`. A name such as `build/` matches at any depth; `*.log` matches file or directory names at any depth. Patterns containing `/`, such as `src/generated/`, match from the project root. Matching is case-insensitive; `*` in a path pattern can span `/`. Matching directories are pruned. Negation and absolute/parent paths are unsupported. These are explicit AppTrail patterns, not `.gitignore` rules. Excluded paths do not appear as capture warnings or changes. Rules are saved with each checkpoint and reused for comparisons, including after restarting AppTrail. -With Git installed, checkpoint details show the full commit ID, branch (`HEAD` when detached), commit subject, author, commit date, and whether the repository has uncommitted or untracked changes. Git inspection is local and read-only; it does not fetch, checkout, or modify commits. Git status describes the whole repository, including files excluded from AppTrail. Without Git, outside a repository, or before the first commit, checkpoints still work and identify Git details as unavailable. The comparison HTML report includes Git evidence and exclusion rules for both observations. +With Git installed, checkpoint details show the full commit ID, branch (`HEAD` when detached), commit subject, author, commit date, and an explicit unknown working-tree state. Git inspection is local and read-only; it does not fetch, checkout, or modify commits. Working-tree status is not inspected. Without Git, outside a repository, or before the first commit, checkpoints still work and identify Git details as unavailable. The comparison HTML report includes Git evidence and exclusion rules for both observations. A checkpoint is a sequential observation, not a backup or a Git commit. Git metadata is read after the files are captured; keep the project idle for consistent evidence. Renaming a checkpoint through history changes its display name without rewriting the original capture name or exclusion policy. Notes, Trash, and restore work as before. Project paths and source contents remain local unless you share an exported report or snapshot. @@ -268,3 +268,5 @@ Reports retain their exported theme, syntax/word colors, whitespace filter, and Use **Expand all unchanged** to reveal every unchanged section in both snapshots, or **Collapse all unchanged** to return to a compact review. The section counter shows how many are open. These buttons leave source text, syntax/word colors, search highlights, and change navigation intact. Searching or navigating to a hidden match reopens its section. Controls are disabled when the export contains no collapsed-context sections; export from the app with Collapse unchanged lines enabled to create them. Individual section summaries still work, including without JavaScript. Re-export older reports to get the buttons. To keep temporary CI storage small, automatic pushes test/build without uploading a duplicate workflow artifact. Manual workflow runs retain their downloadable build artifact for one day. Published release downloads are separate and retained. + +Git checkpoint capture observes commit metadata only; working-tree state is unknown. New snapshots store `dirty: null` with a reason. Legacy Boolean states remain readable; older AppTrail versions may reject these new snapshots. diff --git a/apptrail/checkpoints.py b/apptrail/checkpoints.py index 9084fc9..3234ad4 100644 --- a/apptrail/checkpoints.py +++ b/apptrail/checkpoints.py @@ -1,5 +1,11 @@ """Project checkpoint capture policy and read-only local Git observations.""" import fnmatch +import os +import re +import shutil +import threading +import time +from datetime import datetime from pathlib import Path import subprocess @@ -29,25 +35,98 @@ def excluded(path, rules): return False +def _git_executable(folder): + # Search absolute PATH entries only; Windows which() also searches CWD. + for entry in os.get_exec_path(): + directory = Path(entry) + if not directory.is_absolute(): + continue + candidate = shutil.which('git', path=str(directory)) + if candidate: + executable = Path(candidate).resolve() + if (executable.parent == directory.resolve() + and not executable.is_relative_to(folder)): + return str(executable) + raise ValueError('Git metadata unavailable: no installed Git outside the project.') + + +def _git_output(command, environment, cwd, deadline): + if time.monotonic() >= deadline: + raise ValueError('Git metadata unavailable: observation timed out.') + buffers = [bytearray(), bytearray()] + overflow = threading.Event() + with subprocess.Popen(command, cwd=cwd, env=environment, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=False, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) as process: + def collect(stream, buffer): + while chunk := stream.read1(4096): + if len(buffer) + len(chunk) > 65536: + overflow.set() + return + buffer.extend(chunk) + readers = [threading.Thread(target=collect, args=(stream, buffer), daemon=True) + for stream, buffer in zip((process.stdout, process.stderr), buffers)] + for reader in readers: + reader.start() + try: + while process.poll() is None or any(reader.is_alive() for reader in readers): + if overflow.is_set(): + raise ValueError('Git metadata unavailable: output limit exceeded.') + if time.monotonic() >= deadline: + raise ValueError('Git metadata unavailable: observation timed out.') + overflow.wait(min(0.01, max(0, deadline - time.monotonic()))) + if overflow.is_set(): + raise ValueError('Git metadata unavailable: output limit exceeded.') + return process.returncode, buffers[0].decode('utf-8', errors='replace').strip() + finally: + if process.poll() is None: + process.kill() + process.wait() + for reader in readers: + reader.join(timeout=0.1) + + def git_details(folder): - def git(*args): - result = subprocess.run(['git', '--no-optional-locks', '-C', str(folder), *args], - capture_output=True, text=True, encoding='utf-8', errors='replace', - timeout=10, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - if result.returncode: - raise ValueError('Git metadata unavailable (folder may not be a repository or have a commit).') - return result.stdout.strip() try: + folder = Path(folder).resolve() + executable = _git_executable(folder) + environment = {k: v for k, v in os.environ.items() if not k.upper().startswith('GIT_')} + environment.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_SYSTEM=os.devnull, + GIT_CONFIG_GLOBAL=os.devnull, GIT_TERMINAL_PROMPT='0') + command = [executable, '--no-pager', '--no-optional-locks', '--no-replace-objects', + '--no-lazy-fetch', '-c', 'log.showSignature=false', '-c', 'log.mailmap=false', + '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + os.devnull, + '-c', 'protocol.allow=never', '-C', str(folder)] + deadline = time.monotonic() + 10 + + def git(*args, detached=False): + code, output = _git_output(command + list(args), environment, + str(Path(executable).parent), deadline) + if detached and code == 1: + return 'HEAD' + if code: + raise ValueError('Git metadata unavailable (unsupported Git, repository, or commit).') + return output + top = git('rev-parse', '--show-toplevel') - commit = git('rev-parse', '--verify', 'HEAD') - return {'status': 'available', 'repository': top, 'commit': commit, - 'branch': git('rev-parse', '--abbrev-ref', 'HEAD'), - 'subject': git('log', '-1', '--format=%s'), - 'author': git('log', '-1', '--format=%an'), - 'committed_at': git('log', '-1', '--format=%cI'), - 'dirty': bool(git('status', '--porcelain', '--untracked-files=normal'))} - except (OSError, ValueError, subprocess.TimeoutExpired) as exc: - return {'status': 'unavailable', 'message': str(exc)} + commit = git('rev-parse', '--verify', 'HEAD^{commit}') + if not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', commit): + raise ValueError('Git metadata unavailable: invalid commit ID.') + branch = git('symbolic-ref', '--quiet', '--short', 'HEAD', detached=True) + metadata = git('show', '--no-patch', '--no-show-signature', + '--format=%s%x00%an%x00%cI', commit, '--').split('\0') + if len(metadata) != 3: + raise ValueError('Git metadata unavailable: malformed commit metadata.') + subject, author, committed_at = metadata + if not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})', committed_at): + raise ValueError('Git metadata unavailable: invalid commit timestamp.') + datetime.fromisoformat(committed_at) + return {'status': 'available', 'repository': top, 'commit': commit, 'branch': branch, + 'subject': subject, 'author': author, 'committed_at': committed_at, + 'dirty': None, + 'dirty_reason': 'Working-tree state was not inspected; metadata-only capture.'} + except (OSError, ValueError) as exc: + return {'status': 'unavailable', 'message': str(exc)[:500]} def capture_checkpoint(history, folder, name, exclusions, capture_text=True, diff --git a/apptrail/core.py b/apptrail/core.py index 9f7455d..5d7b64e 100644 --- a/apptrail/core.py +++ b/apptrail/core.py @@ -169,7 +169,9 @@ def load_snapshot(path): if git.get('status') not in ('available', 'unavailable'): raise ValueError('Invalid checkpoint Git status.') if git['status'] == 'available': - if any(not isinstance(git.get(k), str) for k in ('repository', 'commit', 'branch', 'subject', 'author', 'committed_at')) or type(git.get('dirty')) is not bool: + if any(not isinstance(git.get(k), str) for k in ('repository', 'commit', 'branch', 'subject', 'author', 'committed_at')) or not (type(git.get('dirty')) is bool or ('dirty' in git and git['dirty'] is None + and isinstance(git.get('dirty_reason'), str) and git['dirty_reason'].strip() + and len(git['dirty_reason']) <= 500)): raise ValueError('Invalid checkpoint Git details.') elif not isinstance(git.get('message'), str): raise ValueError('Invalid checkpoint Git message.') diff --git a/apptrail/gui.py b/apptrail/gui.py index e3e7c92..2825b2f 100644 --- a/apptrail/gui.py +++ b/apptrail/gui.py @@ -220,7 +220,7 @@ def history_selection_changed(self, event=None): if len(selected) == 1 and selected[0] in self.history_entries and checkpoint: git = checkpoint['git'] - details = (f"Git {git['commit']} · {git['branch']} · {'uncommitted changes' if git['dirty'] else 'clean'}\n{git['subject'][:120]} · {git['author']} · {git['committed_at']}" + details = (f"Git {git['commit']} · {git['branch']} · {'uncommitted changes present' if git['dirty'] is True else 'clean working tree' if git['dirty'] is False else 'Working-tree state unknown — metadata-only capture.'}\n{git['subject'][:120]} · {git['author']} · {git['committed_at']}" if git['status'] == 'available' else 'Git details unavailable') self.selection_info.set(self.selection_info.get() + '\nCheckpoint · ' + details + '\nExclusions: ' + (', '.join(checkpoint['exclusions']) or 'none')) diff --git a/apptrail/report.py b/apptrail/report.py index 45ab0d5..2dd0fbb 100644 --- a/apptrail/report.py +++ b/apptrail/report.py @@ -61,7 +61,9 @@ def file_details(entry): if checkpoint: git = checkpoint['git'] details = (f"{git['commit']} · {git['branch']} · {git['subject']} · {git['author']} · {git['committed_at']} · " - + ('uncommitted changes present' if git['dirty'] else 'clean working tree') + + ('uncommitted changes present' if git['dirty'] is True else + 'clean working tree' if git['dirty'] is False else + 'Working-tree state unknown — metadata-only capture.') if git['status'] == 'available' else git.get('message', 'Git unavailable')) notes += f'

Git: {e(details)}

Exclusions: {e(", ".join(checkpoint["exclusions"]) or "none")}

' roots.append(f'

{label.title()}

{name}

{e(comparison[label]["root"])}

' diff --git a/tests/test_checkpoints.py b/tests/test_checkpoints.py index 9dad4b4..d408712 100644 --- a/tests/test_checkpoints.py +++ b/tests/test_checkpoints.py @@ -80,9 +80,10 @@ def git(*args): self.assertEqual(details['status'], 'available') self.assertEqual(details['subject'], 'Baseline commit') self.assertEqual(len(details['commit']), 40) - self.assertTrue(details['dirty']) # untracked build output + self.assertIsNone(details['dirty']) + self.assertTrue(details['dirty_reason']) (self.project / 'code.py').write_text('modified') - self.assertTrue(git_details(self.project)['dirty']) + self.assertIsNone(git_details(self.project)['dirty']) git('checkout', '--detach') self.assertEqual(git_details(self.project)['branch'], 'HEAD') @@ -91,3 +92,152 @@ def test_existing_snapshot_is_not_silently_a_checkpoint(self): identity = self.history.add(snapshot_folder(self.project)) with self.assertRaisesRegex(ValueError, 'Select a project checkpoint'): compare_current(self.history, identity) + + +class MetadataSafetyTests(unittest.TestCase): + def test_bounded_processes(self): + import os + import sys + import time + from apptrail.checkpoints import _git_output + for script, seconds, reason in ( + ('import os; os.write(1, b"x" * 70000)', 5, 'output limit'), + ('import os; os.write(2, b"x" * 70000)', 5, 'output limit'), + ('import time; time.sleep(5)', 0.1, 'timed out')): + with self.subTest(script=script), self.assertRaisesRegex(ValueError, reason): + _git_output([sys.executable, '-c', script], os.environ.copy(), + str(Path(sys.executable).parent), time.monotonic() + seconds) + + def test_executable_search_ignores_project_and_relative_path(self): + import os + from apptrail.checkpoints import _git_executable + with tempfile.TemporaryDirectory() as temp: + root = Path(temp).resolve() + (root / 'git.exe').write_bytes(b'not executable') + (root / 'git').write_bytes(b'not executable') + with patch.dict(os.environ, {'PATH': os.pathsep.join([str(root), '.', 'relative'])}): + with self.assertRaises(ValueError): + _git_executable(root) + + def test_allowlist_environment_and_malformed_metadata(self): + import os + calls = [] + def output(command, environment, cwd, deadline): + calls.append(command) + self.assertTrue(Path(command[0]).is_absolute()) + self.assertNotIn('GIT_CONFIG_COUNT', environment) + self.assertEqual(environment['GIT_CONFIG_GLOBAL'], os.devnull) + for flag in ('--no-lazy-fetch', '--no-replace-objects', '--no-pager', + 'protocol.allow=never', 'core.hooksPath=' + os.devnull): + self.assertIn(flag, command) + return 0, next(results) + for metadata in ('subject\0author\0' + '2026-11-03T12:00:00+00:00', 'bad', 's\0a\0bad-date'): + results = iter(['/repo', 'a' * 40, 'main', metadata]) + with patch.dict(os.environ, {'GIT_CONFIG_COUNT': '1'}), patch( + 'apptrail.checkpoints._git_output', side_effect=output): + result = git_details(Path.cwd()) + self.assertEqual(result['status'], 'available' if metadata.startswith('subject') else 'unavailable') + for command in calls: + self.assertFalse(set(command) & {'status', 'log', 'diff'}) + + def test_null_and_legacy_validation_report(self): + from apptrail.core import snapshot_folder, compare + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + data = snapshot_folder(root) + git = dict(status='available', repository='', commit='a' * 40, + branch='main', subject='', author='A', committed_at='date') + data['checkpoint'] = dict(name='Checkpoint', exclusions=[], git=git) + target = root / 'snapshot.json' + for dirty, reason, valid in ((True, None, True), (False, None, True), + (None, 'Not inspected', True), (None, '', False), + (None, 'x' * 501, False), (0, None, False)): + git['dirty'] = dirty + git['dirty_reason'] = reason + target.write_text(json.dumps(data)) + if valid: + loaded = load_snapshot(target) + self.assertIs(loaded['checkpoint']['git']['dirty'], dirty) + html = render_html(compare(loaded, loaded)) + self.assertIn('<subject>', html) + self.assertIn('Working-tree state unknown' if dirty is None else + 'uncommitted changes present' if dirty else 'clean working tree', html) + else: + with self.assertRaises(ValueError): + load_snapshot(target) + del git['dirty'] + target.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + load_snapshot(target) + + def test_hostile_configuration_signed_commit_and_unborn(self): + import os + import sys + from apptrail.checkpoints import _git_executable + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + executable = _git_executable(root) + def git(*args, input=None): + return subprocess.run([executable, '-C', str(root), *args], input=input, + check=True, capture_output=True).stdout.decode().strip() + git('init') + self.assertEqual(git_details(root)['status'], 'unavailable') + sentinel = root / 'executed' + program = root / 'sentinel.py' + program.write_text('from pathlib import Path; Path(' + repr(str(sentinel)) + ').touch()') + shell_command = '"' + sys.executable.replace('\\', '/') + '" "' + str(program).replace('\\', '/') + '"' + signature_program = root / 'sentinel.cmd' + signature_program.write_text('@' + shell_command + '\n') + tree = git('mktree', input=b'') + commit = (f'tree {tree}\nauthor Test 1700000000 +0000\n' + 'committer Test 1700000000 +0000\n' + 'gpgsig -----BEGIN PGP SIGNATURE-----\n fake\n -----END PGP SIGNATURE-----\n' + '\nSigned fixture\n') + oid = git('hash-object', '-t', 'commit', '-w', '--stdin', input=commit.encode()) + git('update-ref', 'HEAD', oid) + for key, value in (('core.fsmonitor', shell_command), ('filter.hostile.clean', shell_command), + ('gpg.program', str(signature_program)), ('log.showSignature', 'true')): + git('config', key, value) + (root / '.gitattributes').write_text('* filter=hostile\n') + (root / 'untracked').write_text('content') + with patch.dict(os.environ, {'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'core.fsmonitor', + 'GIT_CONFIG_VALUE_0': shell_command}): + observed = git_details(root) + self.assertEqual(observed['status'], 'available', observed) + self.assertEqual(observed['subject'], 'Signed fixture') + self.assertFalse(sentinel.exists()) + + # Positive controls: these deliberately unsafe fixture commands must run the sentinel. + git('-c', 'core.fsmonitor=false', 'show', '--no-patch', '--show-signature', oid) + self.assertTrue(sentinel.exists(), 'signature fixture must execute its configured program') + sentinel.unlink() + git('-c', 'core.fsmonitor=false', 'hash-object', '--path=untracked', '--stdin', input=b'content') + self.assertTrue(sentinel.exists(), 'clean filter fixture must execute its configured program') + sentinel.unlink() + git('status', '--porcelain') + self.assertTrue(sentinel.exists(), 'fsmonitor fixture must execute its configured program') + + + def test_gui_state_labels_without_optional_highlighter(self): + import ast + from types import SimpleNamespace + source = Path(__file__).resolve().parents[1] / 'apptrail' / 'gui.py' + tree = ast.parse(source.read_text(encoding='utf-8')) + method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) + and node.name == 'history_selection_changed') + namespace = {} + exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace) + info = SimpleNamespace(value='', set=lambda value: setattr(info, 'value', value), + get=lambda: info.value) + git = dict(status='available', commit='abc', branch='main', subject='Subject', + author='Author', committed_at='Date') + checkpoint = dict(git=git, exclusions=[]) + app = SimpleNamespace(history_tree=SimpleNamespace(selection=lambda: ['id']), + history_entries={'id': dict(notes='', label='Checkpoint')}, + history=SimpleNamespace(load=lambda identity: dict(checkpoint=checkpoint)), + show_trash=SimpleNamespace(get=lambda: False), selection_info=info) + for dirty, label in ((True, 'uncommitted changes present'), (False, 'clean working tree'), + (None, 'Working-tree state unknown — metadata-only capture.')): + git['dirty'] = dirty + namespace['history_selection_changed'](app) + self.assertIn(label, info.value)