envdrift 4.2.1__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.
- envdrift/__init__.py +30 -0
- envdrift/_version.py +34 -0
- envdrift/api.py +192 -0
- envdrift/cli.py +42 -0
- envdrift/cli_commands/__init__.py +1 -0
- envdrift/cli_commands/diff.py +91 -0
- envdrift/cli_commands/encryption.py +630 -0
- envdrift/cli_commands/encryption_helpers.py +93 -0
- envdrift/cli_commands/hook.py +75 -0
- envdrift/cli_commands/init_cmd.py +117 -0
- envdrift/cli_commands/partial.py +222 -0
- envdrift/cli_commands/sync.py +1140 -0
- envdrift/cli_commands/validate.py +109 -0
- envdrift/cli_commands/vault.py +376 -0
- envdrift/cli_commands/version.py +15 -0
- envdrift/config.py +489 -0
- envdrift/constants.json +18 -0
- envdrift/core/__init__.py +30 -0
- envdrift/core/diff.py +233 -0
- envdrift/core/encryption.py +400 -0
- envdrift/core/parser.py +260 -0
- envdrift/core/partial_encryption.py +239 -0
- envdrift/core/schema.py +253 -0
- envdrift/core/validator.py +312 -0
- envdrift/encryption/__init__.py +117 -0
- envdrift/encryption/base.py +217 -0
- envdrift/encryption/dotenvx.py +236 -0
- envdrift/encryption/sops.py +458 -0
- envdrift/env_files.py +60 -0
- envdrift/integrations/__init__.py +21 -0
- envdrift/integrations/dotenvx.py +689 -0
- envdrift/integrations/precommit.py +266 -0
- envdrift/integrations/sops.py +85 -0
- envdrift/output/__init__.py +21 -0
- envdrift/output/rich.py +424 -0
- envdrift/py.typed +0 -0
- envdrift/sync/__init__.py +26 -0
- envdrift/sync/config.py +218 -0
- envdrift/sync/engine.py +383 -0
- envdrift/sync/operations.py +138 -0
- envdrift/sync/result.py +99 -0
- envdrift/vault/__init__.py +107 -0
- envdrift/vault/aws.py +282 -0
- envdrift/vault/azure.py +170 -0
- envdrift/vault/base.py +150 -0
- envdrift/vault/gcp.py +210 -0
- envdrift/vault/hashicorp.py +238 -0
- envdrift-4.2.1.dist-info/METADATA +160 -0
- envdrift-4.2.1.dist-info/RECORD +52 -0
- envdrift-4.2.1.dist-info/WHEEL +4 -0
- envdrift-4.2.1.dist-info/entry_points.txt +2 -0
- envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
envdrift/core/parser.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""ENV file parser with multi-backend encryption detection.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- dotenvx: Values starting with "encrypted:"
|
|
5
|
+
- SOPS: Values starting with "ENC[AES256_GCM,"
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EncryptionStatus(Enum):
|
|
17
|
+
"""Encryption status of an environment variable."""
|
|
18
|
+
|
|
19
|
+
ENCRYPTED = "encrypted" # Encrypted value (dotenvx or SOPS)
|
|
20
|
+
PLAINTEXT = "plaintext" # Unencrypted value
|
|
21
|
+
EMPTY = "empty" # No value (KEY= or KEY="")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EnvVar:
|
|
26
|
+
"""Parsed environment variable."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
value: str
|
|
30
|
+
line_number: int
|
|
31
|
+
encryption_status: EncryptionStatus
|
|
32
|
+
raw_line: str
|
|
33
|
+
encryption_backend: str | None = None # "dotenvx", "sops", or None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_encrypted(self) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Determine whether this environment variable's value is encrypted.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the variable is encrypted, False otherwise.
|
|
42
|
+
"""
|
|
43
|
+
return self.encryption_status == EncryptionStatus.ENCRYPTED
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_empty(self) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Indicates whether the variable's value is empty.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if the variable's value is empty, False otherwise.
|
|
52
|
+
"""
|
|
53
|
+
return self.encryption_status == EncryptionStatus.EMPTY
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class EnvFile:
|
|
58
|
+
"""Parsed .env file."""
|
|
59
|
+
|
|
60
|
+
path: Path
|
|
61
|
+
variables: dict[str, EnvVar] = field(default_factory=dict)
|
|
62
|
+
comments: list[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_encrypted(self) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
Determine whether the file contains at least one encrypted environment variable.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
`true` if at least one variable in the file is encrypted, `false` otherwise.
|
|
71
|
+
"""
|
|
72
|
+
return any(var.is_encrypted for var in self.variables.values())
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_fully_encrypted(self) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Determine whether every non-empty environment variable in the file is encrypted.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
`true` if all non-empty variables have encryption status `ENCRYPTED`, `false` otherwise (also `false` when there are no non-empty variables).
|
|
81
|
+
"""
|
|
82
|
+
non_empty_vars = [v for v in self.variables.values() if not v.is_empty]
|
|
83
|
+
if not non_empty_vars:
|
|
84
|
+
return False
|
|
85
|
+
return all(var.is_encrypted for var in non_empty_vars)
|
|
86
|
+
|
|
87
|
+
def get(self, name: str) -> EnvVar | None:
|
|
88
|
+
"""
|
|
89
|
+
Retrieve the environment variable with the specified name from this EnvFile.
|
|
90
|
+
|
|
91
|
+
Parameters:
|
|
92
|
+
name (str): The variable name to look up.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
EnvVar | None: The matching EnvVar if found, `None` otherwise.
|
|
96
|
+
"""
|
|
97
|
+
return self.variables.get(name)
|
|
98
|
+
|
|
99
|
+
def __contains__(self, name: str) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Determine whether the EnvFile contains a variable with the given name.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if a variable with the given name exists in the file, False otherwise.
|
|
105
|
+
"""
|
|
106
|
+
return name in self.variables
|
|
107
|
+
|
|
108
|
+
def __len__(self) -> int:
|
|
109
|
+
"""
|
|
110
|
+
Number of environment variables contained in the EnvFile.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
int: The count of parsed variables.
|
|
114
|
+
"""
|
|
115
|
+
return len(self.variables)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class EnvParser:
|
|
119
|
+
"""Parse .env files with multi-backend encryption awareness.
|
|
120
|
+
|
|
121
|
+
Handles:
|
|
122
|
+
- Standard KEY=value
|
|
123
|
+
- Quoted values: KEY="value" or KEY='value'
|
|
124
|
+
- dotenvx encrypted: KEY="encrypted:xxxx"
|
|
125
|
+
- SOPS encrypted: KEY="ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]"
|
|
126
|
+
- Comments and blank lines (skipped)
|
|
127
|
+
|
|
128
|
+
Note:
|
|
129
|
+
Multiline values are not currently supported. Each line is parsed
|
|
130
|
+
independently. For multiline secrets (e.g., PEM keys), consider
|
|
131
|
+
base64 encoding or using a single-line escaped format.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# dotenvx encrypted value pattern
|
|
135
|
+
DOTENVX_ENCRYPTED_PATTERN = re.compile(r"^encrypted:")
|
|
136
|
+
|
|
137
|
+
# SOPS encrypted value pattern
|
|
138
|
+
SOPS_ENCRYPTED_PATTERN = re.compile(r"^ENC\[AES256_GCM,")
|
|
139
|
+
|
|
140
|
+
# Combined pattern for backward compatibility
|
|
141
|
+
ENCRYPTED_PATTERN = re.compile(r"^(encrypted:|ENC\[AES256_GCM,)")
|
|
142
|
+
|
|
143
|
+
# Pattern to match KEY=value lines
|
|
144
|
+
LINE_PATTERN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
145
|
+
|
|
146
|
+
def parse(self, path: Path | str) -> EnvFile:
|
|
147
|
+
"""
|
|
148
|
+
Parse a .env file and produce an EnvFile representing its parsed contents.
|
|
149
|
+
|
|
150
|
+
Parameters:
|
|
151
|
+
path (Path | str): Filesystem path to the .env file.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
EnvFile: Parsed file containing variables and comments.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
FileNotFoundError: If the file does not exist.
|
|
158
|
+
"""
|
|
159
|
+
path = Path(path)
|
|
160
|
+
|
|
161
|
+
if not path.exists():
|
|
162
|
+
raise FileNotFoundError(f"ENV file not found: {path}")
|
|
163
|
+
|
|
164
|
+
content = path.read_text(encoding="utf-8")
|
|
165
|
+
env_file = self.parse_string(content)
|
|
166
|
+
env_file.path = path
|
|
167
|
+
|
|
168
|
+
return env_file
|
|
169
|
+
|
|
170
|
+
def parse_string(self, content: str) -> EnvFile:
|
|
171
|
+
"""
|
|
172
|
+
Parse .env formatted text, extracting variables (with detected encryption status) and comments.
|
|
173
|
+
|
|
174
|
+
Parameters:
|
|
175
|
+
content (str): The complete text content of a .env file to parse.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
EnvFile: An EnvFile populated with parsed EnvVar entries keyed by variable name and a list of comment lines.
|
|
179
|
+
"""
|
|
180
|
+
env_file = EnvFile(path=Path())
|
|
181
|
+
lines = content.splitlines()
|
|
182
|
+
|
|
183
|
+
for line_num, line in enumerate(lines, start=1):
|
|
184
|
+
original_line = line
|
|
185
|
+
line = line.strip()
|
|
186
|
+
|
|
187
|
+
# Skip empty lines
|
|
188
|
+
if not line:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Collect comments
|
|
192
|
+
if line.startswith("#"):
|
|
193
|
+
env_file.comments.append(line)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Parse KEY=value
|
|
197
|
+
match = self.LINE_PATTERN.match(line)
|
|
198
|
+
if not match:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
key = match.group(1)
|
|
202
|
+
value = match.group(2).strip()
|
|
203
|
+
|
|
204
|
+
# Remove surrounding quotes
|
|
205
|
+
value = self._unquote(value)
|
|
206
|
+
|
|
207
|
+
# Determine encryption status and backend
|
|
208
|
+
encryption_status, encryption_backend = self._detect_encryption_status(value)
|
|
209
|
+
|
|
210
|
+
env_var = EnvVar(
|
|
211
|
+
name=key,
|
|
212
|
+
value=value,
|
|
213
|
+
line_number=line_num,
|
|
214
|
+
encryption_status=encryption_status,
|
|
215
|
+
raw_line=original_line,
|
|
216
|
+
encryption_backend=encryption_backend,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
env_file.variables[key] = env_var
|
|
220
|
+
|
|
221
|
+
return env_file
|
|
222
|
+
|
|
223
|
+
def _unquote(self, value: str) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Remove a single matching pair of surrounding single or double quotes from the value.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
the unquoted string if the value is enclosed in matching single quotes ('...') or double quotes ("..."); otherwise the original value
|
|
229
|
+
"""
|
|
230
|
+
if len(value) >= 2:
|
|
231
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
232
|
+
value.startswith("'") and value.endswith("'")
|
|
233
|
+
):
|
|
234
|
+
return value[1:-1]
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
def _detect_encryption_status(self, value: str) -> tuple[EncryptionStatus, str | None]:
|
|
238
|
+
"""
|
|
239
|
+
Detects the encryption status and backend of an environment variable value.
|
|
240
|
+
|
|
241
|
+
Parameters:
|
|
242
|
+
value (str): The unquoted value string to classify.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
tuple[EncryptionStatus, str | None]: A tuple of (status, backend) where:
|
|
246
|
+
- status is EncryptionStatus.EMPTY, ENCRYPTED, or PLAINTEXT
|
|
247
|
+
- backend is "dotenvx", "sops", or None
|
|
248
|
+
"""
|
|
249
|
+
if not value:
|
|
250
|
+
return EncryptionStatus.EMPTY, None
|
|
251
|
+
|
|
252
|
+
# Check for dotenvx encrypted format
|
|
253
|
+
if self.DOTENVX_ENCRYPTED_PATTERN.match(value):
|
|
254
|
+
return EncryptionStatus.ENCRYPTED, "dotenvx"
|
|
255
|
+
|
|
256
|
+
# Check for SOPS encrypted format
|
|
257
|
+
if self.SOPS_ENCRYPTED_PATTERN.match(value):
|
|
258
|
+
return EncryptionStatus.ENCRYPTED, "sops"
|
|
259
|
+
|
|
260
|
+
return EncryptionStatus.PLAINTEXT, None
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Partial encryption functionality for envdrift.
|
|
2
|
+
|
|
3
|
+
This module implements a simple build pattern for partial encryption:
|
|
4
|
+
- Source files: .env.{env}.clear + .env.{env}.secret
|
|
5
|
+
- Generated file: .env.{env} (combined with warning header)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from envdrift.config import PartialEncryptionEnvironmentConfig
|
|
13
|
+
from envdrift.integrations.dotenvx import DotenvxError, DotenvxWrapper
|
|
14
|
+
|
|
15
|
+
WARNING_HEADER = """#/---------------------------------------------------/
|
|
16
|
+
#/ WARNING: AUTO-GENERATED FILE /
|
|
17
|
+
#/ DO NOT EDIT THIS FILE DIRECTLY /
|
|
18
|
+
#/ /
|
|
19
|
+
#/ This file is generated by: envdrift push /
|
|
20
|
+
#/ /
|
|
21
|
+
#/ To make changes: /
|
|
22
|
+
#/ 1. Edit: {clear_file:<36}/
|
|
23
|
+
#/ 2. Edit: {secret_file:<36}/
|
|
24
|
+
#/ 3. Run: envdrift pull-partial (to decrypt) /
|
|
25
|
+
#/ 4. Run: envdrift push (to regenerate this) /
|
|
26
|
+
#/---------------------------------------------------/
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PartialEncryptionError(Exception):
|
|
31
|
+
"""Partial encryption operation failed."""
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def encrypt_failed(cls, path: Path, cause: Exception) -> PartialEncryptionError:
|
|
35
|
+
"""Create error for encryption failure."""
|
|
36
|
+
return cls(f"Failed to encrypt {path}: {cause}")
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def decrypt_failed(cls, path: Path, cause: Exception) -> PartialEncryptionError:
|
|
40
|
+
"""Create error for decryption failure."""
|
|
41
|
+
return cls(f"Failed to decrypt {path}: {cause}")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def file_not_found(cls, path: Path) -> PartialEncryptionError:
|
|
45
|
+
"""Create error for missing file."""
|
|
46
|
+
return cls(f"Secret file not found: {path}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_file_encrypted(file_path: Path) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Check if a file contains dotenvx encrypted content.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
file_path: Path to check
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if file contains encrypted: prefix or DOTENV_VAULT markers
|
|
58
|
+
"""
|
|
59
|
+
if not file_path.exists():
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
content = file_path.read_text()
|
|
63
|
+
return "encrypted:" in content or "DOTENV_VAULT" in content
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def combine_files(env_config: PartialEncryptionEnvironmentConfig) -> dict[str, int]:
|
|
67
|
+
"""
|
|
68
|
+
Combine clear and encrypted secret files into a single combined file.
|
|
69
|
+
|
|
70
|
+
Process:
|
|
71
|
+
1. Read .env.{env}.clear (cleartext vars)
|
|
72
|
+
2. Read .env.{env}.secret (encrypted vars)
|
|
73
|
+
3. Combine with warning header → .env.{env}
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
env_config: Environment configuration
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dict with counts: {"clear_lines": X, "secret_vars": Y}
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
PartialEncryptionError: If operation fails
|
|
83
|
+
"""
|
|
84
|
+
clear_file = Path(env_config.clear_file)
|
|
85
|
+
secret_file = Path(env_config.secret_file)
|
|
86
|
+
combined_file = Path(env_config.combined_file)
|
|
87
|
+
|
|
88
|
+
# Read files
|
|
89
|
+
clear_lines = []
|
|
90
|
+
if clear_file.exists():
|
|
91
|
+
clear_lines = clear_file.read_text().splitlines()
|
|
92
|
+
|
|
93
|
+
secret_lines = []
|
|
94
|
+
if secret_file.exists():
|
|
95
|
+
secret_lines = secret_file.read_text().splitlines()
|
|
96
|
+
|
|
97
|
+
# Build combined content with warning header
|
|
98
|
+
warning = WARNING_HEADER.format(
|
|
99
|
+
clear_file=env_config.clear_file,
|
|
100
|
+
secret_file=env_config.secret_file,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
combined_lines = warning.splitlines()
|
|
104
|
+
combined_lines.append("")
|
|
105
|
+
|
|
106
|
+
# Add clear section
|
|
107
|
+
if clear_lines:
|
|
108
|
+
combined_lines.append(f"# From {env_config.clear_file}")
|
|
109
|
+
combined_lines.extend(clear_lines)
|
|
110
|
+
combined_lines.append("")
|
|
111
|
+
|
|
112
|
+
# Add encrypted secret section
|
|
113
|
+
if secret_lines:
|
|
114
|
+
# Skip dotenvx header comments from secret file to avoid clutter
|
|
115
|
+
secret_content = [line for line in secret_lines if not line.strip().startswith("#/---")]
|
|
116
|
+
combined_lines.append(f"# From {env_config.secret_file} (encrypted)")
|
|
117
|
+
combined_lines.extend(secret_content)
|
|
118
|
+
|
|
119
|
+
# Write combined file
|
|
120
|
+
combined_file.write_text("\n".join(combined_lines) + "\n")
|
|
121
|
+
|
|
122
|
+
# Count variables in secret file
|
|
123
|
+
secret_var_count = sum(
|
|
124
|
+
1 for line in secret_lines if "=" in line and not line.strip().startswith("#")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"clear_lines": len(clear_lines),
|
|
129
|
+
"secret_vars": secret_var_count,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def encrypt_secret_file(env_config: PartialEncryptionEnvironmentConfig) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Encrypt the secret file in-place using dotenvx.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
env_config: Environment configuration
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
PartialEncryptionError: If encryption fails
|
|
142
|
+
"""
|
|
143
|
+
secret_file = Path(env_config.secret_file)
|
|
144
|
+
|
|
145
|
+
if not secret_file.exists():
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Check if already encrypted
|
|
149
|
+
if is_file_encrypted(secret_file):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Encrypt using dotenvx
|
|
153
|
+
dotenvx = DotenvxWrapper()
|
|
154
|
+
try:
|
|
155
|
+
dotenvx.encrypt(secret_file)
|
|
156
|
+
except DotenvxError as e:
|
|
157
|
+
raise PartialEncryptionError.encrypt_failed(secret_file, e) from e
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def decrypt_secret_file(env_config: PartialEncryptionEnvironmentConfig) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Decrypt the secret file in-place using dotenvx.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
env_config: Environment configuration
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
PartialEncryptionError: If decryption fails
|
|
169
|
+
"""
|
|
170
|
+
secret_file = Path(env_config.secret_file)
|
|
171
|
+
|
|
172
|
+
if not secret_file.exists():
|
|
173
|
+
raise PartialEncryptionError.file_not_found(secret_file)
|
|
174
|
+
|
|
175
|
+
# Check if already decrypted
|
|
176
|
+
if not is_file_encrypted(secret_file):
|
|
177
|
+
return # Already decrypted
|
|
178
|
+
|
|
179
|
+
# Decrypt using dotenvx
|
|
180
|
+
dotenvx = DotenvxWrapper()
|
|
181
|
+
try:
|
|
182
|
+
dotenvx.decrypt(secret_file)
|
|
183
|
+
except DotenvxError as e:
|
|
184
|
+
raise PartialEncryptionError.decrypt_failed(secret_file, e) from e
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def push_partial_encryption(env_config: PartialEncryptionEnvironmentConfig) -> dict[str, int]:
|
|
188
|
+
"""
|
|
189
|
+
Push operation: Encrypt secret file + combine with clear file.
|
|
190
|
+
|
|
191
|
+
Steps:
|
|
192
|
+
1. Encrypt .env.{env}.secret using dotenvx (in-place)
|
|
193
|
+
2. Combine .env.{env}.clear + encrypted .secret → .env.{env}
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
env_config: Environment configuration
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Dict with stats: {"clear_lines": X, "secret_vars": Y}
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
PartialEncryptionError: If push operation fails
|
|
203
|
+
"""
|
|
204
|
+
# Step 1: Encrypt secret file
|
|
205
|
+
encrypt_secret_file(env_config)
|
|
206
|
+
|
|
207
|
+
# Step 2: Combine files
|
|
208
|
+
stats = combine_files(env_config)
|
|
209
|
+
|
|
210
|
+
return stats
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def pull_partial_encryption(env_config: PartialEncryptionEnvironmentConfig) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
Pull operation: Decrypt secret file for editing.
|
|
216
|
+
|
|
217
|
+
Steps:
|
|
218
|
+
1. Decrypt .env.{env}.secret in-place
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
env_config: Environment configuration
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if file was decrypted, False if already decrypted
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
PartialEncryptionError: If pull operation fails
|
|
228
|
+
"""
|
|
229
|
+
secret_file = Path(env_config.secret_file)
|
|
230
|
+
|
|
231
|
+
if not secret_file.exists():
|
|
232
|
+
raise PartialEncryptionError.file_not_found(secret_file)
|
|
233
|
+
|
|
234
|
+
was_encrypted = is_file_encrypted(secret_file)
|
|
235
|
+
|
|
236
|
+
# Decrypt secret file
|
|
237
|
+
decrypt_secret_file(env_config)
|
|
238
|
+
|
|
239
|
+
return was_encrypted
|