from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterator
_SOPS_ENC_RE = re.compile(r"^ENC\[[A-Z0-9_]+,.*\]$")
_SOPS_DATA_RE = re.compile(r"\bdata:([0-9A-Za-z+/=]+)")
_SOPS_META_KEY = "sops"
def _walk(node: Any, path: tuple[str, ...]) -> Iterator[tuple[tuple[str, ...], Any]]:
if isinstance(node, dict):
for k, v in node.items():
if path == () and k == _SOPS_META_KEY:
continue
yield from _walk(v, path + (str(k),))
elif isinstance(node, list):
for i, v in enumerate(node):
yield from _walk(v, path + (str(i),))
else:
yield path, node
def _is_encrypted(value: Any) -> bool:
return isinstance(value, str) and bool(_SOPS_ENC_RE.match(value))
def _word_for(path: tuple[str, ...]) -> str:
return "sops:" + ".".join(path)
def _format_data_line(value: str, indent: str) -> str:
m = _SOPS_DATA_RE.search(value)
raw = m.group(1) if m else value
lines = []
for i in range(0, len(raw), 48):
lines.append(f"{indent}# <( DATA {raw[i:i + 48]} )>")
return "\n".join(lines)
def import_sops(payload: str, ext: str) -> tuple[str, list[str]]:
if ext in {".yaml", ".yml"}:
try:
import yaml except ImportError as e:
raise SystemExit(
"YAML input requires PyYAML: `pip install pyyaml`, or pass a .json file."
) from e
data = yaml.safe_load(payload)
elif ext == ".json":
data = json.loads(payload)
else:
raise SystemExit(f"unsupported input extension: {ext} (want .yaml or .json)")
if not isinstance(data, dict):
raise SystemExit(
f"SOPS root must be a mapping, got {type(data).__name__}"
)
warnings: list[str] = []
out: list[str] = ["# Imported from SOPS. Each block was an encrypted scalar.",
"# Run `enprot encrypt -w <WORD>=<PASSWORD>` to re-encrypt with enprot-native crypto.",
""]
n_encrypted = 0
n_plain = 0
for path, value in _walk(data, ()):
if _is_encrypted(value):
n_encrypted += 1
word = _word_for(path)
out.append(f"# {'.'.join(path)}")
out.append(f"# <( ENCRYPTED {word} )>")
out.append(_format_data_line(value, " "))
out.append(f"# <( END {word} )>")
out.append("")
else:
n_plain += 1
if value not in (None, "", [], {}):
warnings.append(
f"{'.'.join(path) or '<root>'}: scalar is not SOPS-encrypted; emitting as comment"
)
out.append(f"# {'.'.join(path)} = {value!r} (plaintext in source, kept for reference)")
out.append("")
if n_encrypted == 0:
warnings.append("no ENC[...] values found in input — was this file actually SOPS-encrypted?")
header = (f"# {n_encrypted} encrypted scalar(s), {n_plain} plaintext scalar(s) "
f"skipped/annotated.\n")
return header + "\n".join(out).rstrip() + "\n", warnings
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description=__doc__.splitlines()[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="See docs/cookbooks/quickstart.md § 'Migrating from SOPS' for end-to-end usage.",
)
p.add_argument("input", type=Path, help="SOPS file (.yaml or .json)")
p.add_argument("-o", "--out", type=Path, default=None,
help="Output path (default: stdout)")
args = p.parse_args(argv)
if not args.input.is_file():
p.error(f"input not found: {args.input}")
payload = args.input.read_text(encoding="utf-8")
try:
ept, warnings = import_sops(payload, args.input.suffix.lower())
except SystemExit as e:
print(str(e), file=sys.stderr)
return 2
for w in warnings:
print(f"warning: {w}", file=sys.stderr)
if args.out:
args.out.write_text(ept, encoding="utf-8")
print(f"wrote {args.out} ({len(ept)} bytes)", file=sys.stderr)
else:
sys.stdout.write(ept)
return 0
if __name__ == "__main__":
sys.exit(main())