Repository: pre-commit/pre-commit

Issue:
special option to `pre-commit run` which works against a readonly filesystem
(mostly as a hardening measure for [pre-commit.ci](https://pre-commit.ci))

I believe the only current r/w operation on the store is to mark the configuration as used.  this could be skipped and installations could raise an error

(I might be missing something)

easiest reproduction is via docker:

```console
$ docker run --rm -ti -e PRE_COMMIT_HOME=/pc --workdir /src -v ~/workspace/pre-commit:/src:ro -v $PWD/pc:/pc:rw $IMG pre-commit install-hooks
[INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Initializing environment for https://gitlab.com/pycqa/flake8.
[INFO] Initializing environment for https://gitlab.com/pycqa/flake8:flake8-typing-imports==1.6.0.
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-autopep8.
[INFO] Initializing environment for https://github.com/pre-commit/pre-commit.
[INFO] Initializing environment for https://github.com/asottile/pyupgrade.
[INFO] Initializing environment for https://github.com/asottile/reorder_python_imports.
[INFO] Initializing environment for https://github.com/asottile/add-trailing-comma.
[INFO] Initializing environment for https://github.com/asottile/setup-cfg-fmt.
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-mypy.
[INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://gitlab.com/pycqa/flake8.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/mirrors-autopep8.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/pre-commit.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/asottile/pyupgrade.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/asottile/reorder_python_imports.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/asottile/add-trailing-comma.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/asottile/setup-cfg-fmt.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/mirrors-mypy.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
$ docker run --rm -ti -e PRE_COMMIT_HOME=/pc --workdir /src -v ~/workspace/pre-commit:/src:ro -v $PWD/pc:/pc:ro $IMG pre-commit run --all-files
An unexpected error has occurred: OperationalError: attempt to write a readonly database
Check the log at /pc/pre-commit.log
Traceback (most recent call last):
  File "/venv/lib/python3.8/site-packages/pre_commit/error_handler.py", line 56, in error_handler
    yield
  File "/venv/lib/python3.8/site-packages/pre_commit/main.py", line 351, in main
    store.mark_config_used(args.config)
  File "/venv/lib/python3.8/site-packages/pre_commit/store.py", line 228, in mark_config_used
    db.execute('INSERT OR IGNORE INTO configs VALUES (?)', (path,))
sqlite3.OperationalError: attempt to write a readonly database

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/venv/bin/pre-commit", line 8, in <module>
    sys.exit(main())
  File "/venv/lib/python3.8/site-packages/pre_commit/main.py", line 404, in main
    raise AssertionError(
  File "/usr/lib/python3.8/contextlib.py", line 131, in __exit__
    self.gen.throw(type, value, traceback)
  File "/venv/lib/python3.8/site-packages/pre_commit/error_handler.py", line 64, in error_handler
    _log_and_exit(msg, e, traceback.format_exc())
  File "/venv/lib/python3.8/site-packages/pre_commit/error_handler.py", line 24, in _log_and_exit
    with open(log_path, 'wb') as log:
OSError: [Errno 30] Read-only file system: '/pc/pre-commit.log'
```

may also need another place to put `pre-commit.log` as well 🤔 


Discussion:

Resolving patch:
diff --git a/pre_commit/store.py b/pre_commit/store.py
--- a/pre_commit/store.py
+++ b/pre_commit/store.py
@@ -43,6 +43,10 @@ class Store:
     def __init__(self, directory: Optional[str] = None) -> None:
         self.directory = directory or Store.get_default_directory()
         self.db_path = os.path.join(self.directory, 'db.db')
+        self.readonly = (
+            os.path.exists(self.directory) and
+            not os.access(self.directory, os.W_OK)
+        )
 
         if not os.path.exists(self.directory):
             os.makedirs(self.directory, exist_ok=True)
@@ -218,6 +222,8 @@ def _create_config_table(self, db: sqlite3.Connection) -> None:
         )
 
     def mark_config_used(self, path: str) -> None:
+        if self.readonly:  # pragma: win32 no cover
+            return
         path = os.path.realpath(path)
         # don't insert config files that do not exist
         if not os.path.exists(path):


Test patch:
diff --git a/tests/store_test.py b/tests/store_test.py
--- a/tests/store_test.py
+++ b/tests/store_test.py
@@ -1,5 +1,6 @@
 import os.path
 import sqlite3
+import stat
 from unittest import mock
 
 import pytest
@@ -12,6 +13,7 @@
 from testing.fixtures import git_dir
 from testing.util import cwd
 from testing.util import git_commit
+from testing.util import xfailif_windows
 
 
 def test_our_session_fixture_works():
@@ -217,3 +219,27 @@ def test_select_all_configs_roll_forward(store):
 def test_mark_config_as_used_roll_forward(store, tmpdir):
     _simulate_pre_1_14_0(store)
     test_mark_config_as_used(store, tmpdir)
+
+
+@xfailif_windows  # pragma: win32 no cover
+def test_mark_config_as_used_readonly(tmpdir):
+    cfg = tmpdir.join('f').ensure()
+    store_dir = tmpdir.join('store')
+    # make a store, then we'll convert its directory to be readonly
+    assert not Store(str(store_dir)).readonly  # directory didn't exist
+    assert not Store(str(store_dir)).readonly  # directory did exist
+
+    def _chmod_minus_w(p):
+        st = os.stat(p)
+        os.chmod(p, st.st_mode & ~(stat.S_IWUSR | stat.S_IWOTH | stat.S_IWGRP))
+
+    _chmod_minus_w(store_dir)
+    for fname in os.listdir(store_dir):
+        assert not os.path.isdir(fname)
+        _chmod_minus_w(os.path.join(store_dir, fname))
+
+    store = Store(str(store_dir))
+    assert store.readonly
+    # should be skipped due to readonly
+    store.mark_config_used(str(cfg))
+    assert store.select_all_configs() == []