codeimprint 0.5.0a11__py3-none-any.whl

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,181 @@
1
+ """Where the client keeps the server URL and token, and how settings are resolved.
2
+
3
+ Order: command-line flag, then environment (CODEIMPRINT_URL, CODEIMPRINT_TOKEN), then the
4
+ user config file written by `imprint login`.
5
+
6
+ A server behind **Cloudflare Access** (a development lock) also needs a service token: the
7
+ ``CF-Access-Client-Id`` and ``CF-Access-Client-Secret`` headers. They come from
8
+ CODEIMPRINT_CF_ACCESS_CLIENT_ID / CODEIMPRINT_CF_ACCESS_CLIENT_SECRET, or from
9
+ `imprint login --cf-access`, and are sent **only** to that one server's host (never to
10
+ another, never over plain HTTP except to this machine).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+ CONFIG_NAME = "config.json"
23
+
24
+
25
+ def config_dir() -> Path:
26
+ override = os.environ.get("CODEIMPRINT_CONFIG_DIR")
27
+ if override:
28
+ return Path(override)
29
+ if os.name == "nt":
30
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
31
+ elif sys.platform == "darwin":
32
+ base = Path.home() / "Library" / "Application Support"
33
+ else:
34
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
35
+ return base / "codeimprint"
36
+
37
+
38
+ def config_path() -> Path:
39
+ return config_dir() / CONFIG_NAME
40
+
41
+
42
+ def load_user_config() -> dict:
43
+ path = config_path()
44
+ if not path.is_file():
45
+ return {}
46
+ try:
47
+ return json.loads(path.read_text(encoding="utf-8"))
48
+ except (OSError, ValueError):
49
+ return {}
50
+
51
+
52
+ def _write(settings: dict) -> Path:
53
+ path = config_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ data = json.dumps(settings, indent=2) + "\n"
56
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
57
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
58
+ f.write(data)
59
+ if os.name != "nt":
60
+ path.chmod(0o600)
61
+ return path
62
+
63
+
64
+ def save_user_config(url: str, token: str | None) -> Path:
65
+ """Write the config readable by the current user only (a sign-in session is kept)."""
66
+ keep = ("session", "cf_access", "hubs")
67
+ settings = {k: v for k, v in load_user_config().items() if k in keep}
68
+ settings.update(url=url, token=token or None)
69
+ return _write(settings)
70
+
71
+
72
+ def save_session(url: str, session: dict) -> Path:
73
+ """Keep a sign-in session (from `imprint login`) for this server, readable by the user
74
+ only. The session token is short-lived; nothing else about the login is stored."""
75
+ settings = load_user_config()
76
+ settings.setdefault("url", url)
77
+ settings["session"] = {"url": url.rstrip("/"), "token": session["token"],
78
+ "expires_at": session["expires_at"],
79
+ "provider": session.get("provider"), "handle": session.get("handle")}
80
+ return _write(settings)
81
+
82
+
83
+ def load_session(url: str) -> dict | None:
84
+ """The stored session for this server, unless it has expired."""
85
+ from datetime import datetime, timezone
86
+
87
+ s = load_user_config().get("session") or {}
88
+ if not s.get("token") or s.get("url") != url.rstrip("/"):
89
+ return None
90
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
91
+ return s if s.get("expires_at", "") > now else None
92
+
93
+
94
+ LOOPBACK = {"localhost", "127.0.0.1", "::1"}
95
+
96
+
97
+ def _host(url: str | None) -> str | None:
98
+ return urlparse(url).hostname if url else None
99
+
100
+
101
+ def cf_access_for(url: str) -> tuple[str, str] | None:
102
+ """The Cloudflare Access service token to send to ``url``, or None.
103
+
104
+ From the environment, it is sent to the host of CODEIMPRINT_CF_ACCESS_HOST, else of
105
+ CODEIMPRINT_URL, else of the saved server URL; saved by `imprint login --cf-access`, to the
106
+ host it was saved for. Only over HTTPS (or plain HTTP to this machine)."""
107
+ parts = urlparse(url or "")
108
+ host = parts.hostname
109
+ if not host or not (parts.scheme == "https" or (parts.scheme == "http" and host in LOOPBACK)):
110
+ return None
111
+ stored = load_user_config()
112
+ env_id = os.environ.get("CODEIMPRINT_CF_ACCESS_CLIENT_ID", "").strip()
113
+ env_secret = os.environ.get("CODEIMPRINT_CF_ACCESS_CLIENT_SECRET", "").strip()
114
+ if env_id and env_secret:
115
+ target = (os.environ.get("CODEIMPRINT_CF_ACCESS_HOST", "").strip()
116
+ or _host(os.environ.get("CODEIMPRINT_URL")) or _host(stored.get("url")))
117
+ if target and target.lower() == host.lower():
118
+ return env_id, env_secret
119
+ saved = stored.get("cf_access") or {}
120
+ if (saved.get("host", "").lower() == host.lower() and saved.get("client_id")
121
+ and saved.get("client_secret")):
122
+ return saved["client_id"], saved["client_secret"]
123
+ return None
124
+
125
+
126
+ def save_cf_access(url: str, client_id: str, client_secret: str) -> Path:
127
+ """Keep a Cloudflare Access service token for this server's host (user-only file)."""
128
+ host = _host(url)
129
+ if not host:
130
+ raise ValueError("not a server URL")
131
+ settings = load_user_config()
132
+ settings["cf_access"] = {"host": host, "client_id": client_id,
133
+ "client_secret": client_secret}
134
+ return _write(settings)
135
+
136
+
137
+ @dataclass
138
+ class Settings:
139
+ url: str | None
140
+ token: str | None
141
+
142
+
143
+ def hub_url(server_url: str | None, flag: str | None = None) -> str | None:
144
+ """The hub that shows this server's seals (<hub>/i/<ID>): ``--hub-url``, else
145
+ CODEIMPRINT_HUB_URL, else the ``public_url`` the server itself reported (cached per server
146
+ URL by `imprint login` and by every seal), else None: then no link is printed."""
147
+ for value in (flag, os.environ.get("CODEIMPRINT_HUB_URL")):
148
+ if value and value.strip():
149
+ return value.strip().rstrip("/")
150
+ if server_url:
151
+ return (load_user_config().get("hubs") or {}).get(server_url.rstrip("/"))
152
+ return None
153
+
154
+
155
+ def save_hub(server_url: str, public_url) -> str | None:
156
+ """Remember the hub a server reported. A local hub (localhost) is believed only from a
157
+ local server, and a remote one only over HTTPS; anything else is not kept."""
158
+ if not isinstance(public_url, str) or not public_url.strip():
159
+ return None
160
+ hub = public_url.strip().rstrip("/")
161
+ parts, server_host = urlparse(hub), _host(server_url)
162
+ if not parts.hostname or parts.scheme not in ("https", "http"):
163
+ return None
164
+ if parts.hostname in LOOPBACK and server_host not in LOOPBACK:
165
+ return None
166
+ if parts.scheme == "http" and parts.hostname not in LOOPBACK:
167
+ return None
168
+ settings = load_user_config()
169
+ hubs = settings.get("hubs") or {}
170
+ if hubs.get(server_url.rstrip("/")) != hub:
171
+ hubs[server_url.rstrip("/")] = hub
172
+ settings["hubs"] = hubs
173
+ _write(settings)
174
+ return hub
175
+
176
+
177
+ def resolve(url_flag: str | None = None) -> Settings:
178
+ stored = load_user_config()
179
+ url = url_flag or os.environ.get("CODEIMPRINT_URL") or stored.get("url")
180
+ token = os.environ.get("CODEIMPRINT_TOKEN") or stored.get("token")
181
+ return Settings(url=url.rstrip("/") if url else None, token=token or None)
@@ -0,0 +1,148 @@
1
+ """Read a Git repository for incremental sealing: tree listings, blobs and commit links.
2
+
3
+ Everything comes from `git` itself (ls-tree, cat-file, rev-parse, rev-list): the client
4
+ computes no hashes of its own over code. Blob and tree IDs are passed to the server as
5
+ claims; it never publishes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+
14
+ class GitError(Exception):
15
+ """Not a Git repository, or a git command failed."""
16
+
17
+
18
+ def _git(root: Path, *args: str, input: bytes | None = None) -> bytes:
19
+ try:
20
+ done = subprocess.run(["git", "-C", str(root), *args], input=input, capture_output=True,
21
+ check=True)
22
+ except FileNotFoundError as exc:
23
+ raise GitError("git is not installed") from exc
24
+ except subprocess.CalledProcessError as exc:
25
+ raise GitError(exc.stderr.decode(errors="replace").strip() or "git failed") from exc
26
+ return done.stdout
27
+
28
+
29
+ def is_repo(root: Path) -> bool:
30
+ try:
31
+ return _git(root, "rev-parse", "--is-inside-work-tree").strip() == b"true"
32
+ except GitError:
33
+ return False
34
+
35
+
36
+ def toplevel(root: Path) -> Path:
37
+ return Path(_git(root, "rev-parse", "--show-toplevel").decode().strip())
38
+
39
+
40
+ def git_dir(root: Path) -> Path:
41
+ path = Path(_git(root, "rev-parse", "--git-dir").decode().strip())
42
+ return path if path.is_absolute() else Path(root) / path
43
+
44
+
45
+ def prefix(root: Path) -> str:
46
+ """The folder's path inside the repository ('' at the top), with a trailing '/'."""
47
+ return _git(root, "rev-parse", "--show-prefix").decode().strip()
48
+
49
+
50
+ def resolve(root: Path, rev: str) -> str:
51
+ return _git(root, "rev-parse", "--verify", f"{rev}^{{commit}}").decode().strip()
52
+
53
+
54
+ def remote_url(root: Path, name: str = "origin") -> str | None:
55
+ try:
56
+ url = _git(root, "remote", "get-url", name).decode().strip()
57
+ except GitError:
58
+ return None
59
+ return url or None
60
+
61
+
62
+ def repository_name(url: str | None) -> tuple[str | None, str | None]:
63
+ """(host, "owner/repo") from a remote URL: https://host/owner/repo(.git),
64
+ git@host:owner/repo(.git) or ssh://git@host/owner/repo. Credentials in the URL are
65
+ dropped; (None, None) when it cannot be read."""
66
+ if not url:
67
+ return None, None
68
+ from urllib.parse import urlsplit
69
+
70
+ if "://" in url:
71
+ parts = urlsplit(url)
72
+ host, path = (parts.hostname or ""), parts.path
73
+ elif ":" in url and "@" in url.split(":", 1)[0]:
74
+ host_part, path = url.split(":", 1)
75
+ host = host_part.rsplit("@", 1)[1]
76
+ else:
77
+ return None, None
78
+ path = path.strip("/").removesuffix(".git").strip("/")
79
+ bits = [b for b in path.split("/") if b]
80
+ if len(bits) < 2:
81
+ return host.lower() or None, bits[0] if bits else None
82
+ return host.lower() or None, "/".join(bits[-2:])
83
+
84
+
85
+ def has_commits(root: Path) -> bool:
86
+ try:
87
+ resolve(root, "HEAD")
88
+ return True
89
+ except GitError:
90
+ return False
91
+
92
+
93
+ def commit_info(root: Path, commit: str) -> dict:
94
+ tree = _git(root, "rev-parse", f"{commit}^{{tree}}").decode().strip()
95
+ line = _git(root, "rev-list", "--parents", "-n", "1", commit).decode().split()
96
+ ref = ""
97
+ try:
98
+ ref = _git(root, "symbolic-ref", "-q", "HEAD").decode().strip()
99
+ except GitError:
100
+ pass
101
+ return {"commit": commit, "tree": tree, "parents": line[1:], "ref": ref or None}
102
+
103
+
104
+ def tags_at(root: Path, commit: str) -> list[str]:
105
+ """Tags pointing at a commit, sorted (``git tag --points-at``)."""
106
+ return sorted(_git(root, "tag", "--points-at", commit).decode().split())
107
+
108
+
109
+ def commits(root: Path, revision_range: str) -> list[str]:
110
+ """Commits in a range, oldest first (``git rev-list --reverse``)."""
111
+ return _git(root, "rev-list", "--reverse", revision_range).decode().split()
112
+
113
+
114
+ def ls_tree(root: Path, commit: str, under: str = "") -> list[dict]:
115
+ """Regular files of a commit: [{path (relative to ``under``), blob, size}]."""
116
+ out = _git(root, "ls-tree", "-r", "-z", "--long", "--full-tree", commit)
117
+ files = []
118
+ for record in out.split(b"\0"):
119
+ if not record:
120
+ continue
121
+ meta, _, path = record.partition(b"\t")
122
+ mode, kind, blob, size = meta.decode().split()
123
+ name = path.decode("utf-8", errors="surrogateescape")
124
+ if kind != "blob" or mode == "120000" or not name.startswith(under):
125
+ continue # submodules, symlinks, other folders
126
+ files.append({"path": name[len(under):], "blob": blob, "size": int(size)})
127
+ return files
128
+
129
+
130
+ def read_blobs(root: Path, blob_ids: list[str]):
131
+ """Yield (blob ID, content) for each ID, via one `git cat-file --batch` process."""
132
+ if not blob_ids:
133
+ return
134
+ proc = subprocess.Popen(["git", "-C", str(root), "cat-file", "--batch"],
135
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE)
136
+ try:
137
+ for blob in blob_ids:
138
+ proc.stdin.write(blob.encode() + b"\n")
139
+ proc.stdin.flush()
140
+ header = proc.stdout.readline().split()
141
+ if len(header) != 3:
142
+ raise GitError(f"blob {blob} is missing from the repository")
143
+ data = proc.stdout.read(int(header[2]))
144
+ proc.stdout.read(1) # the newline after each object
145
+ yield blob, data
146
+ finally:
147
+ proc.stdin.close()
148
+ proc.wait()
@@ -0,0 +1,128 @@
1
+ """Offline verification of .imprint files (docs/imprint-format.md).
2
+
3
+ Checks only the public file format: canonical JSON, SHA-256 digest, optional Ed25519
4
+ signature and the Imprint ID. No secret is involved.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+
14
+ FORMAT = "imprint"
15
+ ID_PREFIX = "IMP1"
16
+ RELEASE_PREFIX = "REL1" # release records: same shape, own namespace
17
+ BODY_CHECKED = (ID_PREFIX, RELEASE_PREFIX) # frozen: check character over the body only
18
+ RECORD_PREFIXES = ("IDT1", "REV1", "MBR1", "END1", "CLM1", "XFR1", "ASG1", "DCL1", "CTB1",
19
+ "NTC1", "DSC1", "INT1") # ownership records: check over prefix + body
20
+ ALL_PREFIXES = BODY_CHECKED + RECORD_PREFIXES
21
+ ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford Base32: no I, L, O, U
22
+
23
+
24
+ def b64d(text: str) -> bytes:
25
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
26
+
27
+
28
+ def canonical(doc: dict) -> bytes:
29
+ """Sorted keys, no whitespace, UTF-8 without escaping (RFC 8785 for this data)."""
30
+ return json.dumps(doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
31
+
32
+
33
+ def digest_of(doc: dict) -> str:
34
+ body = {k: v for k, v in doc.items() if k != "integrity"}
35
+ return hashlib.sha256(canonical(body)).hexdigest()
36
+
37
+
38
+ def _check_char(body: str) -> str:
39
+ return ALPHABET[hashlib.sha256(body.encode("ascii")).digest()[0] >> 3]
40
+
41
+
42
+ def _check_for(prefix: str, body: str) -> str:
43
+ return _check_char(body) if prefix in BODY_CHECKED else _check_char(f"{prefix}-{body}")
44
+
45
+
46
+ def imprint_id(digest_hex: str, prefix: str = ID_PREFIX) -> str:
47
+ if prefix not in ALL_PREFIXES:
48
+ raise ValueError(f"unknown ID prefix {prefix!r}")
49
+ value = int(digest_hex[:20], 16) # first 80 bits
50
+ body = "".join(ALPHABET[(value >> (75 - 5 * i)) & 31] for i in range(16))
51
+ groups = "-".join(body[i:i + 4] for i in range(0, 16, 4))
52
+ return f"{prefix}-{groups}-{_check_for(prefix, body)}"
53
+
54
+
55
+ def record_id(prefix: str, digest_hex: str) -> str:
56
+ """An ownership record's ID from the SHA-256 of its canonical document."""
57
+ if prefix not in RECORD_PREFIXES:
58
+ raise ValueError(f"not an ownership record prefix: {prefix!r}")
59
+ return imprint_id(digest_hex, prefix)
60
+
61
+
62
+ def release_id(digest_hex: str) -> str:
63
+ """A release record's ID: REL1-..., from the SHA-256 of its release document."""
64
+ return imprint_id(digest_hex, RELEASE_PREFIX)
65
+
66
+
67
+ def clean_id(text: str) -> str:
68
+ parts = text.strip().upper().split("-")
69
+ if len(parts) == 6:
70
+ parts[1:6] = [p.replace("O", "0").replace("I", "1").replace("L", "1") for p in parts[1:6]]
71
+ return "-".join(parts)
72
+
73
+
74
+ def is_valid_id(text: str, prefixes: tuple[str, ...] = (ID_PREFIX,)) -> bool:
75
+ parts = clean_id(text).split("-")
76
+ if len(parts) != 6 or parts[0] not in prefixes or len(parts[5]) != 1:
77
+ return False
78
+ body = "".join(parts[1:5])
79
+ return (len(body) == 16 and all(c in ALPHABET for c in body)
80
+ and _check_for(parts[0], body) == parts[5])
81
+
82
+
83
+ def verify_document(doc: dict) -> dict:
84
+ """Return a status dict; ``valid`` is True only if every present check passes."""
85
+ if not isinstance(doc, dict) or doc.get("format") != FORMAT:
86
+ raise ValueError("not an Imprint document")
87
+ integrity = doc.get("integrity") or {}
88
+ stored = integrity.get("sha256")
89
+ recomputed = digest_of(doc)
90
+ status = {"id": imprint_id(stored) if stored else None, "digest_ok": stored == recomputed,
91
+ "signed": False, "signature_ok": None, "signer": None, "valid": False}
92
+ sig = integrity.get("signature")
93
+ if sig:
94
+ from cryptography.exceptions import InvalidSignature
95
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
96
+
97
+ status["signed"] = True
98
+ try:
99
+ if sig.get("alg") != "Ed25519":
100
+ raise ValueError(f"unsupported signature algorithm {sig.get('alg')!r}")
101
+ public = b64d(sig["public_key"])
102
+ Ed25519PublicKey.from_public_bytes(public).verify(b64d(sig["value"]),
103
+ bytes.fromhex(stored))
104
+ status["signature_ok"] = True
105
+ status["signer"] = hashlib.sha256(public).hexdigest()[:16]
106
+ except (InvalidSignature, ValueError, KeyError, TypeError):
107
+ status["signature_ok"] = False
108
+ status["valid"] = bool(stored) and status["digest_ok"] and status["signature_ok"] is not False
109
+ return status
110
+
111
+
112
+ def load(path: str | Path) -> dict:
113
+ return json.loads(Path(path).read_text(encoding="utf-8"))
114
+
115
+
116
+ def save(doc: dict, path: str | Path) -> Path:
117
+ """Write the document the way the server stores it (sorted keys, one-space indent)."""
118
+ out = Path(path)
119
+ out.parent.mkdir(parents=True, exist_ok=True)
120
+ out.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n", encoding="utf-8")
121
+ return out
122
+
123
+
124
+ def badge(imprint_id_text: str, public_url: str) -> str:
125
+ """README badge Markdown (rust, B23C0C), linking to the seal's page on the hub."""
126
+ shield = imprint_id_text.replace("-", "--")
127
+ return (f"[![Sealed by CodeImprint](https://img.shields.io/badge/CodeImprint-{shield}-B23C0C)]"
128
+ f"({public_url.rstrip('/')}/i/{imprint_id_text})")