gitstore 0.1.2__tar.gz → 0.2.0__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.
- gitstore-0.2.0/PKG-INFO +136 -0
- gitstore-0.2.0/README.md +125 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/pyproject.toml +1 -1
- gitstore-0.2.0/src/gitstore/__init__.py +13 -0
- gitstore-0.2.0/src/gitstore/client.py +298 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore/crypto_ops.py +33 -9
- gitstore-0.2.0/src/gitstore/github_ops.py +57 -0
- gitstore-0.2.0/src/gitstore.egg-info/PKG-INFO +136 -0
- gitstore-0.1.2/PKG-INFO +0 -105
- gitstore-0.1.2/README.md +0 -94
- gitstore-0.1.2/src/gitstore/__init__.py +0 -8
- gitstore-0.1.2/src/gitstore/client.py +0 -363
- gitstore-0.1.2/src/gitstore/github_ops.py +0 -61
- gitstore-0.1.2/src/gitstore.egg-info/PKG-INFO +0 -105
- {gitstore-0.1.2 → gitstore-0.2.0}/setup.cfg +0 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore/config.py +0 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore.egg-info/SOURCES.txt +0 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore.egg-info/dependency_links.txt +0 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore.egg-info/requires.txt +0 -0
- {gitstore-0.1.2 → gitstore-0.2.0}/src/gitstore.egg-info/top_level.txt +0 -0
gitstore-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitstore
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories.
|
|
5
|
+
Author: artitzco
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.31.0
|
|
10
|
+
Requires-Dist: utilitz[crypto]
|
|
11
|
+
|
|
12
|
+
# gitstore
|
|
13
|
+
|
|
14
|
+
`gitstore` is a focused Python package for one goal:
|
|
15
|
+
|
|
16
|
+
- upload encrypted files/folders to a GitHub-backed repo
|
|
17
|
+
- download and restore encrypted GitHub files later
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install gitstore
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Dependency on `utilitz`
|
|
26
|
+
|
|
27
|
+
This project depends on:
|
|
28
|
+
|
|
29
|
+
- `utilitz[crypto]`
|
|
30
|
+
- `requests`
|
|
31
|
+
|
|
32
|
+
## Project Structure
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
gitstore/
|
|
36
|
+
src/gitstore/
|
|
37
|
+
__init__.py
|
|
38
|
+
client.py
|
|
39
|
+
config.py
|
|
40
|
+
crypto_ops.py
|
|
41
|
+
github_ops.py
|
|
42
|
+
pyproject.toml
|
|
43
|
+
README.md
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Core API
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from gitstore import upload_to_github, download_from_github
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Upload
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from gitstore import upload_to_github
|
|
56
|
+
|
|
57
|
+
record = upload_to_github(
|
|
58
|
+
source_path="C:/data/documento.pdf", # file or directory
|
|
59
|
+
name="documento_ventas_q2", # logical name only
|
|
60
|
+
repo_path="C:/repos/my-publish-repo", # required
|
|
61
|
+
password=None, # default: uses GITSTORE_PASSWORD
|
|
62
|
+
vault_dir="vault", # default
|
|
63
|
+
request_timeout=60, # default
|
|
64
|
+
security_level="high", # default
|
|
65
|
+
replace_existing=True, # default
|
|
66
|
+
force_upload=False, # default
|
|
67
|
+
commit_message=None, # default: automatic message
|
|
68
|
+
)
|
|
69
|
+
print(record)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Upload behavior:
|
|
73
|
+
|
|
74
|
+
- computes `source_hash` from source content before encryption
|
|
75
|
+
- skips upload if same `name` already has same `source_hash`
|
|
76
|
+
- set `force_upload=True` to upload even when the current source matches the remote metadata
|
|
77
|
+
- stores artifact as `vault/<name>.asc`
|
|
78
|
+
- stores metadata in `vault/index.json`
|
|
79
|
+
- removes temporary encrypted file after processing
|
|
80
|
+
|
|
81
|
+
## Valid Names
|
|
82
|
+
|
|
83
|
+
`name` is the logical identifier used to upload an artifact.
|
|
84
|
+
It is intentionally strict to keep Git paths predictable:
|
|
85
|
+
|
|
86
|
+
- allowed characters: letters, numbers, dots, underscores, and hyphens
|
|
87
|
+
- must start with a letter or number
|
|
88
|
+
- spaces and path separators are not allowed
|
|
89
|
+
|
|
90
|
+
Valid examples:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
documento_ventas_q2
|
|
94
|
+
maindb-version-0.1
|
|
95
|
+
backup.2026_05
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Invalid examples:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
maindb -version 0.1
|
|
102
|
+
../secret
|
|
103
|
+
folder/documento
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Download
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from gitstore import download_from_github
|
|
110
|
+
|
|
111
|
+
output_path = download_from_github(
|
|
112
|
+
github_url="https://github.com/USER/REPO/blob/main/vault/documento_ventas_q2.asc",
|
|
113
|
+
password=None, # default: uses GITSTORE_PASSWORD
|
|
114
|
+
output_path=None, # default: restores in the current working directory
|
|
115
|
+
overwrite=False, # default
|
|
116
|
+
force_download=False, # default
|
|
117
|
+
)
|
|
118
|
+
print(output_path)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Download behavior:
|
|
122
|
+
|
|
123
|
+
- accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
|
|
124
|
+
- skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
|
|
125
|
+
- set `force_download=True` to download even when the local output appears aligned
|
|
126
|
+
- downloads the encrypted `.asc` file to a temporary location
|
|
127
|
+
- restores files or directories automatically with `utilitz.crypto`
|
|
128
|
+
- removes the temporary encrypted file after restore
|
|
129
|
+
|
|
130
|
+
## Password Source
|
|
131
|
+
|
|
132
|
+
`upload_to_github` and `download_from_github` auto-detect password from:
|
|
133
|
+
|
|
134
|
+
- `GITSTORE_PASSWORD`
|
|
135
|
+
|
|
136
|
+
If `password` is not passed, the environment variable is used.
|
gitstore-0.2.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# gitstore
|
|
2
|
+
|
|
3
|
+
`gitstore` is a focused Python package for one goal:
|
|
4
|
+
|
|
5
|
+
- upload encrypted files/folders to a GitHub-backed repo
|
|
6
|
+
- download and restore encrypted GitHub files later
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install gitstore
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Dependency on `utilitz`
|
|
15
|
+
|
|
16
|
+
This project depends on:
|
|
17
|
+
|
|
18
|
+
- `utilitz[crypto]`
|
|
19
|
+
- `requests`
|
|
20
|
+
|
|
21
|
+
## Project Structure
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
gitstore/
|
|
25
|
+
src/gitstore/
|
|
26
|
+
__init__.py
|
|
27
|
+
client.py
|
|
28
|
+
config.py
|
|
29
|
+
crypto_ops.py
|
|
30
|
+
github_ops.py
|
|
31
|
+
pyproject.toml
|
|
32
|
+
README.md
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Core API
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from gitstore import upload_to_github, download_from_github
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Upload
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from gitstore import upload_to_github
|
|
45
|
+
|
|
46
|
+
record = upload_to_github(
|
|
47
|
+
source_path="C:/data/documento.pdf", # file or directory
|
|
48
|
+
name="documento_ventas_q2", # logical name only
|
|
49
|
+
repo_path="C:/repos/my-publish-repo", # required
|
|
50
|
+
password=None, # default: uses GITSTORE_PASSWORD
|
|
51
|
+
vault_dir="vault", # default
|
|
52
|
+
request_timeout=60, # default
|
|
53
|
+
security_level="high", # default
|
|
54
|
+
replace_existing=True, # default
|
|
55
|
+
force_upload=False, # default
|
|
56
|
+
commit_message=None, # default: automatic message
|
|
57
|
+
)
|
|
58
|
+
print(record)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Upload behavior:
|
|
62
|
+
|
|
63
|
+
- computes `source_hash` from source content before encryption
|
|
64
|
+
- skips upload if same `name` already has same `source_hash`
|
|
65
|
+
- set `force_upload=True` to upload even when the current source matches the remote metadata
|
|
66
|
+
- stores artifact as `vault/<name>.asc`
|
|
67
|
+
- stores metadata in `vault/index.json`
|
|
68
|
+
- removes temporary encrypted file after processing
|
|
69
|
+
|
|
70
|
+
## Valid Names
|
|
71
|
+
|
|
72
|
+
`name` is the logical identifier used to upload an artifact.
|
|
73
|
+
It is intentionally strict to keep Git paths predictable:
|
|
74
|
+
|
|
75
|
+
- allowed characters: letters, numbers, dots, underscores, and hyphens
|
|
76
|
+
- must start with a letter or number
|
|
77
|
+
- spaces and path separators are not allowed
|
|
78
|
+
|
|
79
|
+
Valid examples:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
documento_ventas_q2
|
|
83
|
+
maindb-version-0.1
|
|
84
|
+
backup.2026_05
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Invalid examples:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
maindb -version 0.1
|
|
91
|
+
../secret
|
|
92
|
+
folder/documento
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Download
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from gitstore import download_from_github
|
|
99
|
+
|
|
100
|
+
output_path = download_from_github(
|
|
101
|
+
github_url="https://github.com/USER/REPO/blob/main/vault/documento_ventas_q2.asc",
|
|
102
|
+
password=None, # default: uses GITSTORE_PASSWORD
|
|
103
|
+
output_path=None, # default: restores in the current working directory
|
|
104
|
+
overwrite=False, # default
|
|
105
|
+
force_download=False, # default
|
|
106
|
+
)
|
|
107
|
+
print(output_path)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Download behavior:
|
|
111
|
+
|
|
112
|
+
- accepts normal GitHub file URLs (`github.com/.../blob/...`) and raw URLs
|
|
113
|
+
- skips download when `output_path` already exists and matches the remote `source_hash` in `vault/index.json`
|
|
114
|
+
- set `force_download=True` to download even when the local output appears aligned
|
|
115
|
+
- downloads the encrypted `.asc` file to a temporary location
|
|
116
|
+
- restores files or directories automatically with `utilitz.crypto`
|
|
117
|
+
- removes the temporary encrypted file after restore
|
|
118
|
+
|
|
119
|
+
## Password Source
|
|
120
|
+
|
|
121
|
+
`upload_to_github` and `download_from_github` auto-detect password from:
|
|
122
|
+
|
|
123
|
+
- `GITSTORE_PASSWORD`
|
|
124
|
+
|
|
125
|
+
If `password` is not passed, the environment variable is used.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitstore"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Utilities to encrypt files/directories with utilitz and exchange them through GitHub repositories."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import GitStoreConfig
|
|
13
|
+
from .github_ops import download_raw_file, git_add_commit_push, normalize_github_file_url
|
|
14
|
+
from .crypto_ops import decrypt_auto, encrypt_directory, encrypt_file
|
|
15
|
+
|
|
16
|
+
DEFAULT_PASSWORD_ENV_VAR = "GITSTORE_PASSWORD"
|
|
17
|
+
_VALID_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
18
|
+
_NAME_RULES_MESSAGE = (
|
|
19
|
+
"name must be a simple identifier using only letters, numbers, dots, "
|
|
20
|
+
"underscores, and hyphens. It must start with a letter or number and must "
|
|
21
|
+
"not contain spaces or path separators."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class StoredArtifact:
|
|
27
|
+
name: str
|
|
28
|
+
artifact: str
|
|
29
|
+
artifact_hash: str
|
|
30
|
+
source_hash: str
|
|
31
|
+
is_directory: bool
|
|
32
|
+
created_at_utc: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_password(password: str | None, password_env_var: str) -> str:
|
|
36
|
+
resolved_password = password or os.getenv(password_env_var)
|
|
37
|
+
if not resolved_password:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"Password was not provided and no environment variable was found. "
|
|
40
|
+
f"Set '{password_env_var}' or pass password explicitly."
|
|
41
|
+
)
|
|
42
|
+
return resolved_password
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_name(name: str) -> str:
|
|
46
|
+
if not isinstance(name, str) or not name:
|
|
47
|
+
raise ValueError(_NAME_RULES_MESSAGE)
|
|
48
|
+
if name != name.strip():
|
|
49
|
+
raise ValueError(_NAME_RULES_MESSAGE)
|
|
50
|
+
if any(sep in name for sep in ("/", "\\")):
|
|
51
|
+
raise ValueError(_NAME_RULES_MESSAGE)
|
|
52
|
+
if not _VALID_NAME_RE.fullmatch(name):
|
|
53
|
+
raise ValueError(_NAME_RULES_MESSAGE)
|
|
54
|
+
return name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _hash_from_name_or_content(encrypted_path: Path) -> str:
|
|
58
|
+
# utilitz default output name pattern: -confidential-<24hex>.asc
|
|
59
|
+
m = re.search(r"-confidential-([0-9a-fA-F]{24})", encrypted_path.name)
|
|
60
|
+
if m:
|
|
61
|
+
return m.group(1).lower()
|
|
62
|
+
|
|
63
|
+
hasher = hashlib.sha256()
|
|
64
|
+
with open(encrypted_path, "rb") as f:
|
|
65
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
66
|
+
hasher.update(chunk)
|
|
67
|
+
return hasher.hexdigest()[:24]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hmac_source(path: Path, secret: str) -> str:
|
|
71
|
+
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
|
72
|
+
|
|
73
|
+
if path.is_file():
|
|
74
|
+
mac = hmac.new(key, digestmod=hashlib.sha256)
|
|
75
|
+
mac.update(b"F:")
|
|
76
|
+
mac.update(path.name.encode("utf-8"))
|
|
77
|
+
mac.update(b"\0")
|
|
78
|
+
with open(path, "rb") as f:
|
|
79
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
80
|
+
mac.update(chunk)
|
|
81
|
+
return mac.hexdigest()
|
|
82
|
+
|
|
83
|
+
if not path.is_dir():
|
|
84
|
+
raise FileNotFoundError(f"Input path not found: {path}")
|
|
85
|
+
|
|
86
|
+
mac = hmac.new(key, digestmod=hashlib.sha256)
|
|
87
|
+
for file_path in sorted(p for p in path.rglob("*") if p.is_file()):
|
|
88
|
+
rel = file_path.relative_to(path).as_posix()
|
|
89
|
+
mac.update(b"F:")
|
|
90
|
+
mac.update(rel.encode("utf-8"))
|
|
91
|
+
mac.update(b"\0")
|
|
92
|
+
with open(file_path, "rb") as f:
|
|
93
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
94
|
+
mac.update(chunk)
|
|
95
|
+
mac.update(b"\n")
|
|
96
|
+
return mac.hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def download_from_github(
|
|
100
|
+
github_url: str,
|
|
101
|
+
password: str | None = None,
|
|
102
|
+
output_path: str | None = None,
|
|
103
|
+
overwrite: bool = False,
|
|
104
|
+
force_download: bool = False,
|
|
105
|
+
request_timeout: int = 60,
|
|
106
|
+
password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
|
|
107
|
+
) -> str:
|
|
108
|
+
resolved_password = _resolve_password(password, password_env_var)
|
|
109
|
+
config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
|
|
110
|
+
raw_url = normalize_github_file_url(github_url)
|
|
111
|
+
if output_path is not None and not overwrite and not force_download:
|
|
112
|
+
existing_output = Path(output_path).expanduser().resolve()
|
|
113
|
+
if existing_output.exists():
|
|
114
|
+
remote_record = _load_remote_record_for_artifact(raw_url, request_timeout)
|
|
115
|
+
remote_source_hash = str(remote_record.get("source_hash") or "").lower()
|
|
116
|
+
if remote_source_hash and _hmac_source(existing_output, config.password).lower() == remote_source_hash:
|
|
117
|
+
print(f"[gitstore] Skip download: '{raw_url}' already restored at '{existing_output}'.")
|
|
118
|
+
return str(existing_output)
|
|
119
|
+
|
|
120
|
+
temp_dir = None
|
|
121
|
+
if output_path is None:
|
|
122
|
+
fd, temp_name = tempfile.mkstemp(
|
|
123
|
+
prefix=".gitstore_download_",
|
|
124
|
+
suffix=".asc",
|
|
125
|
+
dir=Path.cwd(),
|
|
126
|
+
)
|
|
127
|
+
os.close(fd)
|
|
128
|
+
temp_file = Path(temp_name)
|
|
129
|
+
else:
|
|
130
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_download_"))
|
|
131
|
+
temp_file = temp_dir / "artifact.asc"
|
|
132
|
+
try:
|
|
133
|
+
download_raw_file(raw_url, str(temp_file), timeout=request_timeout)
|
|
134
|
+
restored_path = decrypt_auto(
|
|
135
|
+
encrypted_path=str(temp_file),
|
|
136
|
+
config=config,
|
|
137
|
+
output_path=output_path,
|
|
138
|
+
overwrite=overwrite,
|
|
139
|
+
)
|
|
140
|
+
print(f"[gitstore] Downloaded and restored '{raw_url}' -> '{restored_path}'.")
|
|
141
|
+
return restored_path
|
|
142
|
+
finally:
|
|
143
|
+
temp_file.unlink(missing_ok=True)
|
|
144
|
+
if temp_dir is not None:
|
|
145
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load_remote_record_for_artifact(raw_url: str, request_timeout: int) -> dict:
|
|
149
|
+
manifest_url = raw_url.rsplit("/", 1)[0] + "/index.json"
|
|
150
|
+
artifact_name = raw_url.rsplit("/", 1)[-1]
|
|
151
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="gitstore_manifest_"))
|
|
152
|
+
temp_manifest = temp_dir / "index.json"
|
|
153
|
+
try:
|
|
154
|
+
try:
|
|
155
|
+
download_raw_file(manifest_url, str(temp_manifest), timeout=request_timeout)
|
|
156
|
+
with open(temp_manifest, "r", encoding="utf-8") as f:
|
|
157
|
+
manifest = json.load(f)
|
|
158
|
+
except Exception:
|
|
159
|
+
return {}
|
|
160
|
+
finally:
|
|
161
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
162
|
+
|
|
163
|
+
if not isinstance(manifest, dict):
|
|
164
|
+
return {}
|
|
165
|
+
for record in manifest.values():
|
|
166
|
+
if isinstance(record, dict) and record.get("artifact") == artifact_name:
|
|
167
|
+
return record
|
|
168
|
+
return {}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def upload_to_github(
|
|
172
|
+
source_path: str,
|
|
173
|
+
name: str,
|
|
174
|
+
repo_path: str,
|
|
175
|
+
password: str | None = None,
|
|
176
|
+
vault_dir: str = "vault",
|
|
177
|
+
request_timeout: int = 60,
|
|
178
|
+
password_env_var: str = DEFAULT_PASSWORD_ENV_VAR,
|
|
179
|
+
security_level: str = "high",
|
|
180
|
+
commit_message: str | None = None,
|
|
181
|
+
replace_existing: bool = True,
|
|
182
|
+
force_upload: bool = False,
|
|
183
|
+
) -> StoredArtifact:
|
|
184
|
+
if not repo_path:
|
|
185
|
+
raise ValueError("repo_path is required.")
|
|
186
|
+
|
|
187
|
+
name = _validate_name(name)
|
|
188
|
+
resolved_password = _resolve_password(password, password_env_var)
|
|
189
|
+
config = GitStoreConfig(password=resolved_password, request_timeout=request_timeout)
|
|
190
|
+
repo = Path(repo_path).expanduser().resolve()
|
|
191
|
+
if not (repo / ".git").exists():
|
|
192
|
+
raise FileNotFoundError(f"Not a git repository: {repo}")
|
|
193
|
+
|
|
194
|
+
vault = vault_dir.strip().strip("/\\") or "vault"
|
|
195
|
+
manifest_rel = f"{vault}/index.json"
|
|
196
|
+
manifest_path = repo / manifest_rel
|
|
197
|
+
|
|
198
|
+
source = Path(source_path).expanduser().resolve()
|
|
199
|
+
if not source.exists():
|
|
200
|
+
raise FileNotFoundError(f"Input path not found: {source}")
|
|
201
|
+
|
|
202
|
+
is_directory = source.is_dir()
|
|
203
|
+
source_hash = _hmac_source(source, config.password)
|
|
204
|
+
manifest = _load_manifest_local(manifest_path)
|
|
205
|
+
existing_record = manifest.get(name)
|
|
206
|
+
if existing_record:
|
|
207
|
+
existing_source_hash = str(existing_record.get("source_hash", "")).lower()
|
|
208
|
+
if existing_source_hash == source_hash.lower() and not force_upload:
|
|
209
|
+
print(
|
|
210
|
+
f"[gitstore] Skip upload: '{name}' already up to date "
|
|
211
|
+
f"(source_hash={source_hash[:24]})."
|
|
212
|
+
)
|
|
213
|
+
return StoredArtifact(
|
|
214
|
+
name=name,
|
|
215
|
+
artifact=str(existing_record["artifact"]),
|
|
216
|
+
artifact_hash=str(existing_record.get("artifact_hash", "")),
|
|
217
|
+
source_hash=existing_source_hash,
|
|
218
|
+
is_directory=bool(existing_record["is_directory"]),
|
|
219
|
+
created_at_utc=str(existing_record["created_at_utc"]),
|
|
220
|
+
)
|
|
221
|
+
if not replace_existing:
|
|
222
|
+
raise ValueError(f"Name '{name}' already exists. Use replace_existing=True to replace it.")
|
|
223
|
+
|
|
224
|
+
if is_directory:
|
|
225
|
+
encrypted_path = Path(
|
|
226
|
+
encrypt_directory(
|
|
227
|
+
source_directory=str(source),
|
|
228
|
+
config=config,
|
|
229
|
+
security_level=security_level,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
encrypted_path = Path(
|
|
234
|
+
encrypt_file(
|
|
235
|
+
source_path=str(source),
|
|
236
|
+
config=config,
|
|
237
|
+
security_level=security_level,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
extension = "".join(encrypted_path.suffixes) or ".asc"
|
|
243
|
+
artifact_hash = _hash_from_name_or_content(encrypted_path)
|
|
244
|
+
artifact_filename = f"{name}{extension}"
|
|
245
|
+
artifact_rel = f"{vault}/{artifact_filename}"
|
|
246
|
+
artifact_abs = repo / artifact_rel
|
|
247
|
+
artifact_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
|
|
249
|
+
shutil.copy2(encrypted_path, artifact_abs)
|
|
250
|
+
|
|
251
|
+
record = StoredArtifact(
|
|
252
|
+
name=name,
|
|
253
|
+
artifact=artifact_filename,
|
|
254
|
+
artifact_hash=artifact_hash,
|
|
255
|
+
source_hash=source_hash,
|
|
256
|
+
is_directory=is_directory,
|
|
257
|
+
created_at_utc=datetime.now(timezone.utc).isoformat(),
|
|
258
|
+
)
|
|
259
|
+
manifest[name] = {
|
|
260
|
+
"name": record.name,
|
|
261
|
+
"artifact": record.artifact,
|
|
262
|
+
"artifact_hash": record.artifact_hash,
|
|
263
|
+
"source_hash": record.source_hash,
|
|
264
|
+
"is_directory": record.is_directory,
|
|
265
|
+
"created_at_utc": record.created_at_utc,
|
|
266
|
+
}
|
|
267
|
+
_save_manifest_local(manifest_path, manifest)
|
|
268
|
+
|
|
269
|
+
message = commit_message or f"gitstore: store '{name}'"
|
|
270
|
+
paths_to_commit = [artifact_rel, manifest_rel]
|
|
271
|
+
git_add_commit_push(
|
|
272
|
+
repo_path=str(repo),
|
|
273
|
+
paths_in_repo=paths_to_commit,
|
|
274
|
+
commit_message=message,
|
|
275
|
+
)
|
|
276
|
+
print(f"[gitstore] Uploaded '{name}' -> '{artifact_rel}' (hash={artifact_hash}).")
|
|
277
|
+
return record
|
|
278
|
+
finally:
|
|
279
|
+
if encrypted_path.exists():
|
|
280
|
+
encrypted_path.unlink()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _load_manifest_local(manifest_path: Path) -> dict:
|
|
284
|
+
if not manifest_path.exists():
|
|
285
|
+
return {}
|
|
286
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
287
|
+
data = json.load(f)
|
|
288
|
+
if not isinstance(data, dict):
|
|
289
|
+
raise ValueError("Manifest format is invalid.")
|
|
290
|
+
return data
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _save_manifest_local(manifest_path: Path, manifest: dict) -> None:
|
|
294
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
with open(manifest_path, "w", encoding="utf-8", newline="\n") as f:
|
|
296
|
+
json.dump(manifest, f, indent=2, sort_keys=False)
|
|
297
|
+
f.write("\n")
|
|
298
|
+
|
|
@@ -76,18 +76,42 @@ def decrypt_file(
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def decrypt_directory(
|
|
80
|
-
encrypted_path: str,
|
|
81
|
-
config: GitStoreConfig,
|
|
79
|
+
def decrypt_directory(
|
|
80
|
+
encrypted_path: str,
|
|
81
|
+
config: GitStoreConfig,
|
|
82
82
|
output_path: str | None = None,
|
|
83
83
|
overwrite: bool = False,
|
|
84
84
|
) -> str:
|
|
85
85
|
path = Path(encrypted_path).expanduser().resolve()
|
|
86
86
|
if not path.is_file():
|
|
87
87
|
raise FileNotFoundError(f"Encrypted file not found: {path}")
|
|
88
|
-
return _crypto().decrypt_directory(
|
|
89
|
-
encrypted_file=str(path),
|
|
90
|
-
password=config.password,
|
|
91
|
-
output_path=output_path,
|
|
92
|
-
overwrite=overwrite,
|
|
93
|
-
)
|
|
88
|
+
return _crypto().decrypt_directory(
|
|
89
|
+
encrypted_file=str(path),
|
|
90
|
+
password=config.password,
|
|
91
|
+
output_path=output_path,
|
|
92
|
+
overwrite=overwrite,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decrypt_auto(
|
|
97
|
+
encrypted_path: str,
|
|
98
|
+
config: GitStoreConfig,
|
|
99
|
+
output_path: str | None = None,
|
|
100
|
+
overwrite: bool = False,
|
|
101
|
+
) -> str:
|
|
102
|
+
try:
|
|
103
|
+
return decrypt_file(
|
|
104
|
+
encrypted_path=encrypted_path,
|
|
105
|
+
config=config,
|
|
106
|
+
output_path=output_path,
|
|
107
|
+
overwrite=overwrite,
|
|
108
|
+
)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
if "directory archive" not in str(exc):
|
|
111
|
+
raise
|
|
112
|
+
return decrypt_directory(
|
|
113
|
+
encrypted_path=encrypted_path,
|
|
114
|
+
config=config,
|
|
115
|
+
output_path=output_path,
|
|
116
|
+
overwrite=overwrite,
|
|
117
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _run_git(repo: Path, args: list[str], capture: bool = False) -> subprocess.CompletedProcess:
|
|
8
|
+
return subprocess.run(
|
|
9
|
+
["git", *args],
|
|
10
|
+
cwd=repo,
|
|
11
|
+
check=True,
|
|
12
|
+
capture_output=capture,
|
|
13
|
+
text=capture,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_github_file_url(url: str) -> str:
|
|
18
|
+
raw_url = (url or "").strip()
|
|
19
|
+
if not raw_url:
|
|
20
|
+
raise ValueError("url is required.")
|
|
21
|
+
if "github.com/" in raw_url and "/blob/" in raw_url:
|
|
22
|
+
return raw_url.replace("https://github.com/", "https://raw.githubusercontent.com/").replace(
|
|
23
|
+
"/blob/",
|
|
24
|
+
"/",
|
|
25
|
+
1,
|
|
26
|
+
)
|
|
27
|
+
return raw_url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def download_raw_file(raw_url: str, output_path: str, timeout: int = 60) -> str:
|
|
31
|
+
destination = Path(output_path).expanduser().resolve()
|
|
32
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
with requests.get(raw_url, timeout=timeout, stream=True) as response:
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
with open(destination, "wb") as f:
|
|
37
|
+
for chunk in response.iter_content(chunk_size=65536):
|
|
38
|
+
if chunk:
|
|
39
|
+
f.write(chunk)
|
|
40
|
+
return str(destination)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def git_add_commit_push(
|
|
44
|
+
repo_path: str,
|
|
45
|
+
paths_in_repo: list[str],
|
|
46
|
+
commit_message: str,
|
|
47
|
+
) -> None:
|
|
48
|
+
repo = Path(repo_path).expanduser().resolve()
|
|
49
|
+
if not (repo / ".git").exists():
|
|
50
|
+
raise FileNotFoundError(f"Not a git repository: {repo}")
|
|
51
|
+
if not paths_in_repo:
|
|
52
|
+
raise ValueError("paths_in_repo must not be empty.")
|
|
53
|
+
|
|
54
|
+
_run_git(repo, ["add", *paths_in_repo])
|
|
55
|
+
_run_git(repo, ["commit", "-m", commit_message])
|
|
56
|
+
_run_git(repo, ["push"])
|
|
57
|
+
|