mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-24 05:06:28 +00:00
feat(rio): rio_v2 is compatible with minio for storing data. (#3115)
* Set up a compatibility layer for replacing old Rio components with new ones. * fix(rio). compress range * feat(rio). Add the experimental feature rio_v2 to support minio data at the binary level. * feat(rio_v2): add sse-c test * test compression component * simple fix * fix minlz encode * fix metadata * fix kms key cache error * Update launch.json * ci: set nix crate download user agent * fix: gate obs pyroscope backend * ignore minio test * fix encrypt check * fix * fix * fix * Update object_usecase.rs * Update ci.yml * fix * ci add rio-v2 test * fix * ci fix * fix * Reconstructed into a more reasonable compatibility mode * fix * fix --------- Signed-off-by: houseme <housemecn@gmail.com> Signed-off-by: 唐小鸭 <tangtang1251@qq.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: cxymds <Cxymds@qq.com> Co-authored-by: 安正超 <anzhengchao@gmail.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# rio-v2 Fixture Tests
|
||||
|
||||
This directory holds interoperability tests for `rustfs-rio-v2`.
|
||||
|
||||
## Generated MinIO fixtures
|
||||
|
||||
The integration test `minio_generated_fixtures.rs` reads raw MinIO backend data generated by
|
||||
`crates/rio-v2/tests/minio_fixture_lab/lab.py`.
|
||||
|
||||
Default fixture root:
|
||||
|
||||
```text
|
||||
crates/rio-v2/tests/fixtures/minio-generated
|
||||
```
|
||||
|
||||
The generated MinIO fixture data is intentionally not checked in and these tests are
|
||||
ignored by default, so they are not part of automated CI runs.
|
||||
|
||||
To generate or expand fixtures locally, use:
|
||||
|
||||
```powershell
|
||||
uv run python .\minio_fixture_lab\lab.py capture-matrix `
|
||||
--root .\rustfs\crates\rio-v2\tests\fixtures\minio-generated `
|
||||
--minio-binary .\rustfs\tmp\minio.darwin-arm64.RELEASE.2025-09-07T16-13-09Z `
|
||||
--endpoint https://127.0.0.1:19000 `
|
||||
--disk-count 1
|
||||
```
|
||||
|
||||
Or point the tests at another generated root:
|
||||
|
||||
```powershell
|
||||
$env:RUSTFS_MINIO_FIXTURE_ROOT = '.\rustfs\tmp\minio-fixture-lab-smoke'
|
||||
cargo +1.95.0 test -p rustfs-rio-v2 --test minio_generated_fixtures -- --ignored
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
The current fixture test covers the full default capture matrix:
|
||||
|
||||
- `sse-s3-singlepart-64k`
|
||||
- `sse-s3-multipart-8m`
|
||||
- `sse-kms-singlepart-64k`
|
||||
- `sse-kms-multipart-8m`
|
||||
- `sse-c-singlepart-64k`
|
||||
- `sse-c-multipart-8m`
|
||||
|
||||
It checks that:
|
||||
|
||||
- raw `xl.meta` can be parsed through `rustfs-filemeta`
|
||||
- singlepart and multipart fixture structures are recognized
|
||||
- SSE-S3, SSE-KMS, and SSE-C metadata markers survive capture
|
||||
- KMS key ids are derived from each fixture's own `manifest.json`, so local static-KMS runs are not tied to one hard-coded key name
|
||||
- SSE-C `HEAD` responses round-trip the expected customer algorithm and customer-key MD5
|
||||
|
||||
These tests do not yet validate full plaintext reconstruction from MinIO-written encrypted data.
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,150 @@
|
||||
# MinIO Fixture Lab
|
||||
|
||||
This lab captures real MinIO backend artifacts into a repeatable local layout for RustFS compatibility tests.
|
||||
|
||||
It now supports two workflows:
|
||||
|
||||
- manual capture from an already exported backend tree
|
||||
- automated capture from a disposable local MinIO run
|
||||
|
||||
## Scope
|
||||
|
||||
Use the manual path after you already have:
|
||||
|
||||
- a running MinIO instance
|
||||
- one or more uploaded objects you want to preserve as fixtures
|
||||
- the backend object tree or object-version directory you want to export
|
||||
|
||||
Use the automated path when you want the lab to:
|
||||
|
||||
- locate a local `minio` binary
|
||||
- start a disposable MinIO instance
|
||||
- upload a predefined SSE fixture case
|
||||
- export the generated backend tree into the lab layout
|
||||
|
||||
## Layout
|
||||
|
||||
The default root is `artifacts/minio-fixture-lab`, which is already ignored by the repository.
|
||||
|
||||
Each case is stored under:
|
||||
|
||||
```text
|
||||
artifacts/minio-fixture-lab/
|
||||
cases/
|
||||
<case-id>/
|
||||
backend/
|
||||
request.json
|
||||
head.json
|
||||
plaintext.sha256
|
||||
manifest.json
|
||||
```
|
||||
|
||||
`manifest.json` is the source of truth for the captured case.
|
||||
|
||||
## Commands
|
||||
|
||||
Initialize the lab root:
|
||||
|
||||
```powershell
|
||||
uv run python D:\Github\rustfs\crates\rio-v2\tests\minio_fixture_lab\lab.py init
|
||||
```
|
||||
|
||||
Capture one case from an existing MinIO backend tree:
|
||||
|
||||
```powershell
|
||||
uv run python D:\Github\rustfs\crates\rio-v2\tests\minio_fixture_lab\lab.py add-case `
|
||||
--case-id sse-kms-singlepart-64k `
|
||||
--bucket demo `
|
||||
--object dir/object.bin `
|
||||
--source-tree D:\minio-data-export\case-tree `
|
||||
--head-json D:\minio-data-export\head.json `
|
||||
--request-json D:\minio-data-export\request.json `
|
||||
--plaintext-sha256 D:\minio-data-export\plaintext.sha256
|
||||
```
|
||||
|
||||
Capture the default automated matrix:
|
||||
|
||||
```powershell
|
||||
uv run python D:\Github\rustfs\crates\rio-v2\tests\minio_fixture_lab\lab.py capture-matrix `
|
||||
--root D:\Github\rustfs\artifacts\minio-fixture-lab `
|
||||
--minio-binary D:\go\bin\minio.exe `
|
||||
--endpoint https://127.0.0.1:19000
|
||||
```
|
||||
|
||||
Capture only one automated case:
|
||||
|
||||
```powershell
|
||||
uv run python D:\Github\rustfs\crates\rio-v2\tests\minio_fixture_lab\lab.py capture-matrix `
|
||||
--root D:\Github\rustfs\artifacts\minio-fixture-lab `
|
||||
--minio-binary D:\Github\rustfs\tmp\minio.windows-amd64.RELEASE.2025-09-07T16-13-09Z.exe `
|
||||
--endpoint https://127.0.0.1:19000 `
|
||||
--case-id sse-s3-singlepart-64k
|
||||
```
|
||||
|
||||
The automated default matrix is intentionally small:
|
||||
|
||||
- `sse-s3-singlepart-64k`
|
||||
- `sse-kms-singlepart-64k`
|
||||
- `sse-c-singlepart-64k`
|
||||
- `sse-s3-multipart-8m`
|
||||
- `sse-kms-multipart-8m`
|
||||
- `sse-c-multipart-8m`
|
||||
|
||||
`64 KiB multipart` is intentionally excluded because S3 multipart semantics require a larger non-final part size.
|
||||
|
||||
## Automated Runner Prerequisites
|
||||
|
||||
The automated runner expects:
|
||||
|
||||
- either `minio` in `PATH`, the bundled `D:\Github\rustfs\tmp\minio.windows-amd64.RELEASE.2025-09-07T16-13-09Z.exe`, `--minio-binary`, or `--minio-root` pointing at a directory containing `minio.exe`
|
||||
- a free local endpoint port
|
||||
|
||||
When the selected matrix includes SSE-C cases, use an `https://` endpoint. The lab will mint a short-lived local self-signed certificate and use its built-in SigV4 S3 client with certificate verification disabled for the disposable local MinIO run.
|
||||
|
||||
The runner provisions backend directories under `--work-root` and exports the resulting backend tree into the lab.
|
||||
|
||||
For local static-KMS runs, pass `--kms-secret-key` or set
|
||||
`MINIO_FIXTURE_LAB_KMS_SECRET_KEY` using MinIO's
|
||||
`<key-id>:<base64-32byte-key>` format. The runner derives the SSE-KMS request
|
||||
key id from that configured key name automatically.
|
||||
|
||||
On some Windows MinIO builds, a multi-disk backend may not come online when all disk directories live on the same volume. If you only want a local smoke run of the upload/export pipeline, try:
|
||||
|
||||
```powershell
|
||||
uv run python D:\Github\rustfs\crates\rio-v2\tests\minio_fixture_lab\lab.py capture-matrix `
|
||||
--root D:\Github\rustfs\artifacts\minio-fixture-lab `
|
||||
--minio-binary D:\go\bin\minio.exe `
|
||||
--endpoint https://127.0.0.1:19000 `
|
||||
--disk-count 1 `
|
||||
--case-id sse-s3-singlepart-64k
|
||||
```
|
||||
|
||||
That is a runner smoke path only. Real compatibility fixtures should still prefer the intended multi-disk backend layout when the local environment can support it.
|
||||
|
||||
## Capture Guidance
|
||||
|
||||
For each case, preserve these inputs when possible:
|
||||
|
||||
- the exact backend tree that contains `xl.meta` and part files
|
||||
- the request shape used to create the object
|
||||
- the API metadata returned by `HEAD Object`
|
||||
- the plaintext digest used for byte-for-byte read verification
|
||||
|
||||
Recommended early matrix:
|
||||
|
||||
- singlepart SSE-S3
|
||||
- singlepart SSE-KMS
|
||||
- multipart SSE-S3
|
||||
- multipart SSE-KMS
|
||||
- compressed + encrypted
|
||||
- range-sensitive sizes around `64 KiB` and `8 MiB`
|
||||
|
||||
## Next Stage
|
||||
|
||||
The current runner covers launch, upload, HEAD capture, and backend export.
|
||||
|
||||
The next iteration should focus on:
|
||||
|
||||
- proving the multi-disk backend path in the target local environment
|
||||
- adding compressed fixtures to the automated matrix
|
||||
- tightening exported tree selection if a narrower object-level slice becomes practical
|
||||
@@ -0,0 +1,963 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path("artifacts/minio-fixture-lab")
|
||||
DEFAULT_WORK_ROOT = DEFAULT_ROOT / "_runner"
|
||||
DEFAULT_MINIO_BINARY = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "tmp"
|
||||
/ "minio.darwin-arm64.RELEASE.2025-09-07T16-13-09Z"
|
||||
)
|
||||
DEFAULT_KMS_KEY_ID = "minio-default-key"
|
||||
DEFAULT_KMS_SECRET_KEY = (
|
||||
"minio-default-key:IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g="
|
||||
)
|
||||
LAB_KMS_SECRET_KEY_ENV = "MINIO_FIXTURE_LAB_KMS_SECRET_KEY"
|
||||
DEFAULT_ENDPOINT = "http://127.0.0.1:9000"
|
||||
DEFAULT_DISK_COUNT = 4
|
||||
DEFAULT_ACCESS_KEY = "minioadmin"
|
||||
DEFAULT_SECRET_KEY = "minioadmin"
|
||||
DEFAULT_REGION = "us-east-1"
|
||||
MULTIPART_CHUNK_SIZE = 5 * 1024 * 1024
|
||||
DEFAULT_BUCKET = "demo"
|
||||
DEFAULT_OBJECT = "dir/object.bin"
|
||||
DEFAULT_SSE_C_KEY_BYTES = bytes(range(32))
|
||||
DEFAULT_SSE_C_KEY_B64 = base64.b64encode(DEFAULT_SSE_C_KEY_BYTES).decode("ascii")
|
||||
DEFAULT_SSE_C_KEY_MD5_B64 = base64.b64encode(hashlib.md5(DEFAULT_SSE_C_KEY_BYTES).digest()).decode("ascii")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def copy_optional_file(source: Path | None, destination: Path) -> str | None:
|
||||
if source is None:
|
||||
return None
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"expected file: {source}")
|
||||
shutil.copy2(source, destination)
|
||||
return destination.name
|
||||
|
||||
|
||||
def copy_tree(source: Path, destination: Path) -> list[str]:
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"source tree does not exist: {source}")
|
||||
if source.is_file():
|
||||
raise ValueError(f"source tree must be a directory: {source}")
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(source, destination)
|
||||
return sorted(
|
||||
str(path.relative_to(destination)).replace("\\", "/")
|
||||
for path in destination.rglob("*")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def read_optional_json(path: Path | None) -> dict[str, Any] | None:
|
||||
if path is None:
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def format_context_header(context: dict[str, str] | None) -> str | None:
|
||||
if not context:
|
||||
return None
|
||||
json_bytes = json.dumps(context, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(json_bytes).decode("ascii")
|
||||
|
||||
|
||||
def format_size_label(size_bytes: int) -> str:
|
||||
if size_bytes == 64 * 1024:
|
||||
return "64k"
|
||||
if size_bytes == 8 * 1024 * 1024:
|
||||
return "8m"
|
||||
return f"{size_bytes}b"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabPaths:
|
||||
root: Path
|
||||
cases: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class MinioLauncher:
|
||||
kind: str
|
||||
command: list[str]
|
||||
workdir: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixtureCase:
|
||||
case_id: str
|
||||
bucket: str
|
||||
object_name: str
|
||||
encryption: str
|
||||
size_bytes: int
|
||||
multipart: bool
|
||||
kms_key_id: str | None = None
|
||||
kms_context: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KmsSecretKeyConfig:
|
||||
secret_key: str
|
||||
key_id: str
|
||||
|
||||
|
||||
def resolve_lab_paths(root: Path) -> LabPaths:
|
||||
root = root.resolve()
|
||||
return LabPaths(root=root, cases=root / "cases")
|
||||
|
||||
|
||||
def resolve_existing_command(name: str) -> Path:
|
||||
resolved = shutil.which(name)
|
||||
if resolved is None:
|
||||
raise FileNotFoundError(f"required command not found in PATH: {name}")
|
||||
return Path(resolved).resolve()
|
||||
|
||||
|
||||
def discover_minio_launcher(
|
||||
explicit_binary: Path | None, minio_root: Path | None
|
||||
) -> MinioLauncher:
|
||||
if explicit_binary is not None:
|
||||
binary = explicit_binary.resolve()
|
||||
if not binary.is_file():
|
||||
raise FileNotFoundError(f"minio binary not found: {binary}")
|
||||
return MinioLauncher(kind="binary", command=[str(binary)], workdir=binary.parent)
|
||||
|
||||
resolved_root = minio_root.resolve() if minio_root is not None else None
|
||||
if resolved_root is not None:
|
||||
candidate_binary = resolved_root / "minio.exe"
|
||||
if candidate_binary.is_file():
|
||||
return MinioLauncher(
|
||||
kind="binary",
|
||||
command=[str(candidate_binary.resolve())],
|
||||
workdir=candidate_binary.resolve().parent,
|
||||
)
|
||||
|
||||
if DEFAULT_MINIO_BINARY.is_file():
|
||||
binary = DEFAULT_MINIO_BINARY.resolve()
|
||||
return MinioLauncher(kind="binary", command=[str(binary)], workdir=binary.parent)
|
||||
|
||||
path_binary = shutil.which("minio")
|
||||
if path_binary is not None:
|
||||
binary = Path(path_binary).resolve()
|
||||
return MinioLauncher(kind="binary", command=[str(binary)], workdir=binary.parent)
|
||||
|
||||
raise FileNotFoundError(
|
||||
"unable to locate MinIO. Provide --minio-binary, point --minio-root at a directory "
|
||||
"containing minio.exe, or ensure minio is in PATH."
|
||||
)
|
||||
|
||||
|
||||
def parse_kms_secret_key(secret_key: str) -> KmsSecretKeyConfig:
|
||||
key_id, separator, raw_key = secret_key.partition(":")
|
||||
if not separator or not key_id or not raw_key:
|
||||
raise ValueError(
|
||||
"kms secret key must be in the form '<key-id>:<base64-32byte-key>'"
|
||||
)
|
||||
return KmsSecretKeyConfig(secret_key=secret_key, key_id=key_id)
|
||||
|
||||
|
||||
def resolve_kms_secret_key(explicit_secret_key: str | None) -> KmsSecretKeyConfig:
|
||||
secret_key = (
|
||||
explicit_secret_key
|
||||
or os.environ.get(LAB_KMS_SECRET_KEY_ENV)
|
||||
or os.environ.get("MINIO_KMS_SECRET_KEY")
|
||||
or DEFAULT_KMS_SECRET_KEY
|
||||
)
|
||||
return parse_kms_secret_key(secret_key)
|
||||
|
||||
|
||||
def build_default_cases(kms_key_id: str = DEFAULT_KMS_KEY_ID) -> list[FixtureCase]:
|
||||
kms_context = {"project": "rio-v2", "stage": "fixture"}
|
||||
cases = []
|
||||
for encryption in ("SSE-S3", "SSE-KMS", "SSE-C"):
|
||||
for multipart, size_bytes in ((False, 64 * 1024), (True, 8 * 1024 * 1024)):
|
||||
shape = "multipart" if multipart else "singlepart"
|
||||
case = FixtureCase(
|
||||
case_id=f"{encryption.lower()}-{shape}-{format_size_label(size_bytes)}",
|
||||
bucket=DEFAULT_BUCKET,
|
||||
object_name=DEFAULT_OBJECT,
|
||||
encryption=encryption,
|
||||
size_bytes=size_bytes,
|
||||
multipart=multipart,
|
||||
kms_key_id=kms_key_id if encryption == "SSE-KMS" else None,
|
||||
kms_context=kms_context if encryption == "SSE-KMS" else None,
|
||||
)
|
||||
cases.append(case)
|
||||
return cases
|
||||
|
||||
|
||||
def build_request_record(case: FixtureCase) -> dict[str, Any]:
|
||||
headers: dict[str, str] = {}
|
||||
if case.encryption == "SSE-S3":
|
||||
headers["x-amz-server-side-encryption"] = "AES256"
|
||||
elif case.encryption == "SSE-KMS":
|
||||
headers["x-amz-server-side-encryption"] = "aws:kms"
|
||||
if case.kms_key_id is not None:
|
||||
headers["x-amz-server-side-encryption-aws-kms-key-id"] = case.kms_key_id
|
||||
context_header = format_context_header(case.kms_context)
|
||||
if context_header is not None:
|
||||
headers["x-amz-server-side-encryption-context"] = context_header
|
||||
elif case.encryption == "SSE-C":
|
||||
headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256"
|
||||
headers["x-amz-server-side-encryption-customer-key"] = DEFAULT_SSE_C_KEY_B64
|
||||
headers["x-amz-server-side-encryption-customer-key-md5"] = DEFAULT_SSE_C_KEY_MD5_B64
|
||||
else:
|
||||
raise ValueError(f"unsupported encryption mode: {case.encryption}")
|
||||
|
||||
record: dict[str, Any] = {
|
||||
"case_id": case.case_id,
|
||||
"bucket": case.bucket,
|
||||
"object": case.object_name,
|
||||
"encryption": case.encryption,
|
||||
"multipart": case.multipart,
|
||||
"size_bytes": case.size_bytes,
|
||||
"headers": headers,
|
||||
}
|
||||
if case.multipart:
|
||||
record["multipart_chunk_size_bytes"] = MULTIPART_CHUNK_SIZE
|
||||
return record
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> int:
|
||||
paths = resolve_lab_paths(args.root)
|
||||
ensure_layout(paths)
|
||||
print(str(paths.root))
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_layout(paths: LabPaths) -> None:
|
||||
ensure_dir(paths.root)
|
||||
ensure_dir(paths.cases)
|
||||
write_json(
|
||||
paths.root / "layout.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"created_at_utc": utc_now(),
|
||||
"root": ".",
|
||||
"description": "Captured MinIO fixture cases for RustFS compatibility validation.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def store_case_artifacts(
|
||||
*,
|
||||
paths: LabPaths,
|
||||
case_id: str,
|
||||
bucket: str,
|
||||
object_name: str,
|
||||
source_tree: Path,
|
||||
version_id: str | None,
|
||||
request_payload: dict[str, Any] | None,
|
||||
head_payload: dict[str, Any] | None,
|
||||
plaintext_sha256: str | None,
|
||||
notes: str | None,
|
||||
capture_payload: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
ensure_dir(paths.cases)
|
||||
case_dir = paths.cases / case_id
|
||||
backend_dir = case_dir / "backend"
|
||||
if case_dir.exists():
|
||||
shutil.rmtree(case_dir)
|
||||
ensure_dir(case_dir)
|
||||
|
||||
exported_files = copy_tree(source_tree.resolve(), backend_dir)
|
||||
request_file = None
|
||||
if request_payload is not None:
|
||||
write_json(case_dir / "request.json", request_payload)
|
||||
request_file = "request.json"
|
||||
|
||||
head_file = None
|
||||
if head_payload is not None:
|
||||
write_json(case_dir / "head.json", head_payload)
|
||||
head_file = "head.json"
|
||||
|
||||
plaintext_file = None
|
||||
if plaintext_sha256 is not None:
|
||||
(case_dir / "plaintext.sha256").write_text(plaintext_sha256 + "\n", encoding="utf-8")
|
||||
plaintext_file = "plaintext.sha256"
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"case_id": case_id,
|
||||
"created_at_utc": utc_now(),
|
||||
"bucket": bucket,
|
||||
"object": object_name,
|
||||
"version_id": version_id,
|
||||
"source_tree": str(source_tree).replace("\\", "/"),
|
||||
"notes": notes,
|
||||
"artifacts": {
|
||||
"backend_dir": "backend",
|
||||
"request_json": request_file,
|
||||
"head_json": head_file,
|
||||
"plaintext_sha256": plaintext_file,
|
||||
},
|
||||
"backend_files": exported_files,
|
||||
}
|
||||
if capture_payload is not None:
|
||||
manifest["capture"] = capture_payload
|
||||
write_json(case_dir / "manifest.json", manifest)
|
||||
return case_dir
|
||||
|
||||
|
||||
def cmd_add_case(args: argparse.Namespace) -> int:
|
||||
paths = resolve_lab_paths(args.root)
|
||||
case_dir = store_case_artifacts(
|
||||
paths=paths,
|
||||
case_id=args.case_id,
|
||||
bucket=args.bucket,
|
||||
object_name=args.object,
|
||||
source_tree=args.source_tree,
|
||||
version_id=args.version_id,
|
||||
request_payload=read_optional_json(args.request_json.resolve() if args.request_json else None),
|
||||
head_payload=read_optional_json(args.head_json.resolve() if args.head_json else None),
|
||||
plaintext_sha256=(
|
||||
args.plaintext_sha256.resolve().read_text(encoding="utf-8").strip()
|
||||
if args.plaintext_sha256
|
||||
else None
|
||||
),
|
||||
notes=args.notes,
|
||||
)
|
||||
print(str(case_dir))
|
||||
return 0
|
||||
|
||||
|
||||
def parse_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
parsed = urlparse(endpoint)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError(f"unsupported endpoint scheme: {endpoint}")
|
||||
if parsed.hostname is None or parsed.port is None:
|
||||
raise ValueError(f"endpoint must include host and port: {endpoint}")
|
||||
return parsed.hostname, parsed.port
|
||||
|
||||
|
||||
def is_https_endpoint(endpoint: str) -> bool:
|
||||
return urlparse(endpoint).scheme == "https"
|
||||
|
||||
|
||||
def ensure_local_tls_certificates(certs_dir: Path, hostname: str) -> None:
|
||||
public_crt = certs_dir / "public.crt"
|
||||
private_key = certs_dir / "private.key"
|
||||
if public_crt.is_file() and private_key.is_file():
|
||||
return
|
||||
|
||||
ensure_dir(certs_dir)
|
||||
openssl = resolve_existing_command("openssl")
|
||||
san_entries = ["DNS:localhost", "IP:127.0.0.1"]
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
san_entries.append(f"IP:{hostname}")
|
||||
except ValueError:
|
||||
san_entries.append(f"DNS:{hostname}")
|
||||
config_path = certs_dir / "openssl.cnf"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[req]",
|
||||
"distinguished_name = req_distinguished_name",
|
||||
"x509_extensions = v3_req",
|
||||
"prompt = no",
|
||||
"[req_distinguished_name]",
|
||||
f"CN = {hostname}",
|
||||
"[v3_req]",
|
||||
f"subjectAltName = {','.join(san_entries)}",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
str(openssl),
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"30",
|
||||
"-keyout",
|
||||
str(private_key),
|
||||
"-out",
|
||||
str(public_crt),
|
||||
"-config",
|
||||
str(config_path),
|
||||
"-extensions",
|
||||
"v3_req",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def build_server_command(
|
||||
launcher: MinioLauncher, endpoint: str, disk_paths: list[Path], certs_dir: Path | None = None
|
||||
) -> list[str]:
|
||||
host, port = parse_endpoint(endpoint)
|
||||
normalized_disk_paths = [str(path.resolve()).replace("\\", "/") for path in disk_paths]
|
||||
command = launcher.command + [
|
||||
"server",
|
||||
"--address",
|
||||
f"{host}:{port}",
|
||||
"--console-address",
|
||||
f"{host}:{port + 1}",
|
||||
]
|
||||
if certs_dir is not None:
|
||||
command += ["--certs-dir", str(certs_dir.resolve())]
|
||||
command += normalized_disk_paths
|
||||
return command
|
||||
|
||||
|
||||
def create_disk_paths(workdir: Path, disk_count: int) -> list[Path]:
|
||||
disks = []
|
||||
for index in range(1, disk_count + 1):
|
||||
disk = ensure_dir(workdir / "backend" / f"disk{index}")
|
||||
disks.append(disk)
|
||||
return disks
|
||||
|
||||
|
||||
def build_payload_file(path: Path, size_bytes: int) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
remaining = size_bytes
|
||||
pattern = bytes(range(251))
|
||||
with path.open("wb") as handle:
|
||||
while remaining > 0:
|
||||
chunk = pattern[: min(len(pattern), remaining)]
|
||||
handle.write(chunk)
|
||||
hasher.update(chunk)
|
||||
remaining -= len(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def minio_env(kms_secret_key: KmsSecretKeyConfig) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env["CI"] = "on"
|
||||
env["MINIO_CI_CD"] = "1"
|
||||
env["MINIO_ROOT_USER"] = "minioadmin"
|
||||
env["MINIO_ROOT_PASSWORD"] = "minioadmin"
|
||||
env["MINIO_KMS_SECRET_KEY"] = kms_secret_key.secret_key
|
||||
env["MINIO_UPDATE"] = "off"
|
||||
return env
|
||||
|
||||
|
||||
class S3Client:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
access_key: str = DEFAULT_ACCESS_KEY,
|
||||
secret_key: str = DEFAULT_SECRET_KEY,
|
||||
region: str = DEFAULT_REGION,
|
||||
) -> None:
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
self.region = region
|
||||
self.parsed_endpoint = urlparse(self.endpoint)
|
||||
self.ssl_context = ssl._create_unverified_context() if is_https_endpoint(endpoint) else None
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
bucket: str | None = None,
|
||||
key: str | None = None,
|
||||
*,
|
||||
query: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: bytes = b"",
|
||||
) -> tuple[int, dict[str, str], bytes]:
|
||||
path = "/"
|
||||
if bucket is not None:
|
||||
path += urllib.parse.quote(bucket, safe="")
|
||||
if key is not None:
|
||||
path += "/" + urllib.parse.quote(key, safe="/")
|
||||
query = query or {}
|
||||
headers = dict(headers or {})
|
||||
signed_headers = self.sign_headers(method, path, query, headers, body)
|
||||
url = self.endpoint + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(sorted(query.items()), quote_via=urllib.parse.quote)
|
||||
request = urllib.request.Request(url, data=body if method != "HEAD" else None, method=method)
|
||||
for name, value in signed_headers.items():
|
||||
request.add_header(name, value)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30, context=self.ssl_context) as response:
|
||||
return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
payload = err.read()
|
||||
raise RuntimeError(
|
||||
f"S3 request failed: {method} {url}\nstatus: {err.code}\nbody:\n{payload.decode('utf-8', errors='replace')}"
|
||||
) from err
|
||||
|
||||
def sign_headers(
|
||||
self,
|
||||
method: str,
|
||||
canonical_uri: str,
|
||||
query: dict[str, str],
|
||||
headers: dict[str, str],
|
||||
body: bytes,
|
||||
) -> dict[str, str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
payload_hash = hashlib.sha256(body).hexdigest()
|
||||
host = self.parsed_endpoint.netloc
|
||||
signed_headers = {k.lower(): v.strip() for k, v in headers.items()}
|
||||
signed_headers["host"] = host
|
||||
signed_headers["x-amz-content-sha256"] = payload_hash
|
||||
signed_headers["x-amz-date"] = amz_date
|
||||
|
||||
canonical_query = "&".join(
|
||||
f"{urllib.parse.quote(k, safe='-_.~')}={urllib.parse.quote(v, safe='-_.~')}"
|
||||
for k, v in sorted(query.items())
|
||||
)
|
||||
canonical_headers = "".join(f"{k}:{signed_headers[k]}\n" for k in sorted(signed_headers))
|
||||
signed_header_names = ";".join(sorted(signed_headers))
|
||||
canonical_request = "\n".join(
|
||||
[
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_query,
|
||||
canonical_headers,
|
||||
signed_header_names,
|
||||
payload_hash,
|
||||
]
|
||||
)
|
||||
scope = f"{date_stamp}/{self.region}/s3/aws4_request"
|
||||
string_to_sign = "\n".join(
|
||||
[
|
||||
"AWS4-HMAC-SHA256",
|
||||
amz_date,
|
||||
scope,
|
||||
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
|
||||
]
|
||||
)
|
||||
signature = hmac.new(
|
||||
self.signing_key(date_stamp),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
signed_headers["authorization"] = (
|
||||
f"AWS4-HMAC-SHA256 Credential={self.access_key}/{scope}, "
|
||||
f"SignedHeaders={signed_header_names}, Signature={signature}"
|
||||
)
|
||||
return signed_headers
|
||||
|
||||
def signing_key(self, date_stamp: str) -> bytes:
|
||||
key = ("AWS4" + self.secret_key).encode("utf-8")
|
||||
for value in (date_stamp, self.region, "s3", "aws4_request"):
|
||||
key = hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest()
|
||||
return key
|
||||
|
||||
def list_buckets(self) -> None:
|
||||
self.request("GET")
|
||||
|
||||
def create_bucket(self, bucket: str) -> None:
|
||||
self.request("PUT", bucket=bucket)
|
||||
|
||||
def put_object(
|
||||
self,
|
||||
bucket: str,
|
||||
key: str,
|
||||
body: bytes,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
self.request("PUT", bucket=bucket, key=key, headers=headers, body=body)
|
||||
|
||||
def create_multipart_upload(self, bucket: str, key: str, headers: dict[str, str]) -> str:
|
||||
_, _, body = self.request("POST", bucket=bucket, key=key, query={"uploads": ""}, headers=headers)
|
||||
root = ET.fromstring(body)
|
||||
upload_id = root.findtext("{*}UploadId")
|
||||
if upload_id is None:
|
||||
raise RuntimeError(f"missing UploadId in create multipart response: {body!r}")
|
||||
return upload_id
|
||||
|
||||
def upload_part(
|
||||
self,
|
||||
bucket: str,
|
||||
key: str,
|
||||
upload_id: str,
|
||||
part_number: int,
|
||||
body: bytes,
|
||||
headers: dict[str, str],
|
||||
) -> str:
|
||||
_, response_headers, _ = self.request(
|
||||
"PUT",
|
||||
bucket=bucket,
|
||||
key=key,
|
||||
query={"partNumber": str(part_number), "uploadId": upload_id},
|
||||
headers=headers,
|
||||
body=body,
|
||||
)
|
||||
etag = response_headers.get("etag")
|
||||
if etag is None:
|
||||
raise RuntimeError("missing ETag in upload part response")
|
||||
return etag
|
||||
|
||||
def complete_multipart_upload(
|
||||
self,
|
||||
bucket: str,
|
||||
key: str,
|
||||
upload_id: str,
|
||||
parts: list[dict[str, Any]],
|
||||
) -> None:
|
||||
body = build_complete_multipart_xml(parts)
|
||||
self.request(
|
||||
"POST",
|
||||
bucket=bucket,
|
||||
key=key,
|
||||
query={"uploadId": upload_id},
|
||||
headers={"content-type": "application/xml"},
|
||||
body=body,
|
||||
)
|
||||
|
||||
def head_object(self, bucket: str, key: str, headers: dict[str, str]) -> dict[str, Any]:
|
||||
_, response_headers, _ = self.request("HEAD", bucket=bucket, key=key, headers=headers)
|
||||
return {
|
||||
"ContentLength": int(response_headers.get("content-length", "0")),
|
||||
"ServerSideEncryption": response_headers.get("x-amz-server-side-encryption"),
|
||||
"SSECustomerAlgorithm": response_headers.get("x-amz-server-side-encryption-customer-algorithm"),
|
||||
"SSECustomerKeyMD5": response_headers.get("x-amz-server-side-encryption-customer-key-md5"),
|
||||
"SSEKMSKeyId": response_headers.get("x-amz-server-side-encryption-aws-kms-key-id"),
|
||||
"VersionId": response_headers.get("x-amz-version-id"),
|
||||
}
|
||||
|
||||
|
||||
def wait_for_minio(endpoint: str, process: subprocess.Popen[str], timeout_seconds: int) -> None:
|
||||
deadline = time.time() + timeout_seconds
|
||||
health_url = endpoint.rstrip("/") + "/minio/health/live"
|
||||
ssl_context = ssl._create_unverified_context() if is_https_endpoint(endpoint) else None
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("MinIO exited before becoming healthy")
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=2, context=ssl_context) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"timed out waiting for MinIO health endpoint: {health_url}")
|
||||
|
||||
|
||||
def wait_for_s3_ready(client: S3Client, timeout_seconds: int) -> None:
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
client.list_buckets()
|
||||
return
|
||||
except RuntimeError:
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"timed out waiting for S3 API readiness: {client.endpoint}")
|
||||
|
||||
|
||||
def create_bucket(client: S3Client, case: FixtureCase) -> None:
|
||||
client.create_bucket(case.bucket)
|
||||
|
||||
|
||||
def build_complete_multipart_xml(parts: list[dict[str, Any]]) -> bytes:
|
||||
root = ET.Element("CompleteMultipartUpload")
|
||||
for part in parts:
|
||||
part_element = ET.SubElement(root, "Part")
|
||||
ET.SubElement(part_element, "PartNumber").text = str(part["PartNumber"])
|
||||
ET.SubElement(part_element, "ETag").text = part["ETag"]
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def upload_case(client: S3Client, case: FixtureCase, payload_file: Path) -> dict[str, Any]:
|
||||
request_record = build_request_record(case)
|
||||
headers = request_record["headers"]
|
||||
headers["content-type"] = "binary/octet-stream"
|
||||
|
||||
if not case.multipart:
|
||||
client.put_object(case.bucket, case.object_name, payload_file.read_bytes(), headers)
|
||||
return request_record
|
||||
|
||||
upload_id = client.create_multipart_upload(case.bucket, case.object_name, headers)
|
||||
|
||||
parts = []
|
||||
with payload_file.open("rb") as handle:
|
||||
part_number = 1
|
||||
while True:
|
||||
chunk = handle.read(MULTIPART_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
part_file = payload_file.parent / f"part-{part_number}.bin"
|
||||
part_file.write_bytes(chunk)
|
||||
part_headers = headers if case.encryption == "SSE-C" else {}
|
||||
etag = client.upload_part(
|
||||
case.bucket,
|
||||
case.object_name,
|
||||
upload_id,
|
||||
part_number,
|
||||
chunk,
|
||||
part_headers,
|
||||
)
|
||||
parts.append({"ETag": etag, "PartNumber": part_number})
|
||||
part_number += 1
|
||||
|
||||
client.complete_multipart_upload(case.bucket, case.object_name, upload_id, parts)
|
||||
return request_record
|
||||
|
||||
|
||||
def head_case(client: S3Client, case: FixtureCase) -> dict[str, Any]:
|
||||
headers = build_request_record(case)["headers"] if case.encryption == "SSE-C" else {}
|
||||
return client.head_object(case.bucket, case.object_name, headers)
|
||||
|
||||
|
||||
def stop_process(process: subprocess.Popen[str]) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=15)
|
||||
|
||||
|
||||
def capture_case(
|
||||
*,
|
||||
paths: LabPaths,
|
||||
launcher: MinioLauncher,
|
||||
endpoint: str,
|
||||
case: FixtureCase,
|
||||
work_root: Path,
|
||||
disk_count: int,
|
||||
timeout_seconds: int,
|
||||
preserve_workdir: bool,
|
||||
kms_secret_key: KmsSecretKeyConfig,
|
||||
) -> Path:
|
||||
case_workdir = work_root / case.case_id
|
||||
if case_workdir.exists():
|
||||
shutil.rmtree(case_workdir)
|
||||
ensure_dir(case_workdir)
|
||||
|
||||
payload_file = case_workdir / "payload.bin"
|
||||
plaintext_sha256 = build_payload_file(payload_file, case.size_bytes)
|
||||
disk_paths = create_disk_paths(case_workdir, disk_count)
|
||||
certs_dir = None
|
||||
if is_https_endpoint(endpoint):
|
||||
host, _ = parse_endpoint(endpoint)
|
||||
certs_dir = case_workdir / "certs"
|
||||
ensure_local_tls_certificates(certs_dir, host)
|
||||
server_command = build_server_command(launcher, endpoint, disk_paths, certs_dir)
|
||||
server_log_path = case_workdir / "server.log"
|
||||
|
||||
with server_log_path.open("w", encoding="utf-8") as server_log:
|
||||
process = subprocess.Popen(
|
||||
server_command,
|
||||
cwd=str(launcher.workdir),
|
||||
env=minio_env(kms_secret_key),
|
||||
stdout=server_log,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
client = S3Client(endpoint)
|
||||
wait_for_minio(endpoint, process, timeout_seconds)
|
||||
wait_for_s3_ready(client, timeout_seconds)
|
||||
create_bucket(client, case)
|
||||
request_payload = upload_case(client, case, payload_file)
|
||||
head_payload = head_case(client, case)
|
||||
finally:
|
||||
stop_process(process)
|
||||
|
||||
case_dir = store_case_artifacts(
|
||||
paths=paths,
|
||||
case_id=case.case_id,
|
||||
bucket=case.bucket,
|
||||
object_name=case.object_name,
|
||||
source_tree=case_workdir / "backend",
|
||||
version_id=head_payload.get("VersionId") if "head_payload" in locals() else None,
|
||||
request_payload=request_payload if "request_payload" in locals() else None,
|
||||
head_payload=head_payload if "head_payload" in locals() else None,
|
||||
plaintext_sha256=plaintext_sha256,
|
||||
notes="Captured automatically from a local disposable MinIO run.",
|
||||
capture_payload={
|
||||
"endpoint": endpoint,
|
||||
"launcher_kind": launcher.kind,
|
||||
"disk_count": disk_count,
|
||||
"kms_key_id": kms_secret_key.key_id,
|
||||
},
|
||||
)
|
||||
|
||||
if not preserve_workdir:
|
||||
shutil.rmtree(case_workdir)
|
||||
return case_dir
|
||||
|
||||
|
||||
def select_cases(case_ids: list[str] | None) -> list[FixtureCase]:
|
||||
cases = build_default_cases()
|
||||
if not case_ids:
|
||||
return cases
|
||||
selected = [case for case in cases if case.case_id in set(case_ids)]
|
||||
if len(selected) != len(set(case_ids)):
|
||||
known = ", ".join(case.case_id for case in cases)
|
||||
missing = sorted(set(case_ids) - {case.case_id for case in selected})
|
||||
raise ValueError(f"unknown case id(s): {', '.join(missing)}; known: {known}")
|
||||
return selected
|
||||
|
||||
|
||||
def cmd_capture_matrix(args: argparse.Namespace) -> int:
|
||||
paths = resolve_lab_paths(args.root)
|
||||
ensure_layout(paths)
|
||||
ensure_dir(args.work_root)
|
||||
|
||||
launcher = discover_minio_launcher(args.minio_binary, args.minio_root)
|
||||
kms_secret_key = resolve_kms_secret_key(args.kms_secret_key)
|
||||
cases = build_default_cases(kms_key_id=kms_secret_key.key_id)
|
||||
if args.case_id:
|
||||
selected_ids = set(args.case_id)
|
||||
cases = [case for case in cases if case.case_id in selected_ids]
|
||||
if len(cases) != len(selected_ids):
|
||||
known = ", ".join(case.case_id for case in build_default_cases())
|
||||
missing = sorted(selected_ids - {case.case_id for case in cases})
|
||||
raise ValueError(f"unknown case id(s): {', '.join(missing)}; known: {known}")
|
||||
for case in cases:
|
||||
case_dir = capture_case(
|
||||
paths=paths,
|
||||
launcher=launcher,
|
||||
endpoint=args.endpoint,
|
||||
case=case,
|
||||
work_root=args.work_root,
|
||||
disk_count=args.disk_count,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
preserve_workdir=args.preserve_workdir,
|
||||
kms_secret_key=kms_secret_key,
|
||||
)
|
||||
print(f"{case.case_id}: {case_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Capture real MinIO backend fixtures into a stable local layout."
|
||||
)
|
||||
parser.set_defaults(func=None)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
init_parser = subparsers.add_parser("init", help="initialize the fixture lab root")
|
||||
init_parser.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="fixture lab root directory")
|
||||
init_parser.set_defaults(func=cmd_init)
|
||||
|
||||
add_case_parser = subparsers.add_parser("add-case", help="capture one fixture case")
|
||||
add_case_parser.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="fixture lab root directory")
|
||||
add_case_parser.add_argument("--case-id", required=True, help="stable fixture case identifier")
|
||||
add_case_parser.add_argument("--bucket", required=True, help="bucket name")
|
||||
add_case_parser.add_argument("--object", required=True, help="object key")
|
||||
add_case_parser.add_argument("--version-id", help="object version id")
|
||||
add_case_parser.add_argument("--source-tree", type=Path, required=True, help="backend object tree to capture")
|
||||
add_case_parser.add_argument("--request-json", type=Path, help="request metadata JSON")
|
||||
add_case_parser.add_argument("--head-json", type=Path, help="HEAD object metadata JSON")
|
||||
add_case_parser.add_argument("--plaintext-sha256", type=Path, help="plaintext sha256 file")
|
||||
add_case_parser.add_argument("--notes", help="free-form notes")
|
||||
add_case_parser.set_defaults(func=cmd_add_case)
|
||||
|
||||
capture_parser = subparsers.add_parser(
|
||||
"capture-matrix",
|
||||
help="run a disposable local MinIO instance and capture the default fixture matrix",
|
||||
)
|
||||
capture_parser.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="fixture lab root directory")
|
||||
capture_parser.add_argument(
|
||||
"--work-root",
|
||||
type=Path,
|
||||
default=DEFAULT_WORK_ROOT,
|
||||
help="temporary runner workspace",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--minio-binary",
|
||||
type=Path,
|
||||
help="explicit path to a minio binary",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--minio-root",
|
||||
type=Path,
|
||||
help="optional directory containing minio.exe",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--endpoint",
|
||||
default=DEFAULT_ENDPOINT,
|
||||
help="local MinIO endpoint used for the disposable run",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--kms-secret-key",
|
||||
help=(
|
||||
"static MinIO KMS secret key in the form "
|
||||
"'<key-id>:<base64-32byte-key>'; overrides "
|
||||
f"{LAB_KMS_SECRET_KEY_ENV}, MINIO_KMS_SECRET_KEY, and the built-in default"
|
||||
),
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--disk-count",
|
||||
type=int,
|
||||
default=DEFAULT_DISK_COUNT,
|
||||
help="number of backend disks to provision per case",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=int,
|
||||
default=60,
|
||||
help="startup timeout for MinIO health checks",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--preserve-workdir",
|
||||
action="store_true",
|
||||
help="keep per-case runner work directories after capture",
|
||||
)
|
||||
capture_parser.add_argument(
|
||||
"--case-id",
|
||||
action="append",
|
||||
help="capture only specific default case ids",
|
||||
)
|
||||
capture_parser.set_defaults(func=cmd_capture_matrix)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if args.func is None:
|
||||
parser.print_help()
|
||||
return 2
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import base64
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def load_lab_module():
|
||||
module_path = Path(__file__).with_name("lab.py")
|
||||
spec = importlib.util.spec_from_file_location("minio_fixture_lab_lab", module_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
lab = load_lab_module()
|
||||
|
||||
|
||||
class DiscoverMinioLauncherTests(unittest.TestCase):
|
||||
def test_prefers_explicit_binary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
binary = Path(temp_dir) / "minio.exe"
|
||||
binary.write_text("", encoding="utf-8")
|
||||
|
||||
launcher = lab.discover_minio_launcher(explicit_binary=binary, minio_root=None)
|
||||
|
||||
self.assertEqual(launcher.command, [str(binary.resolve())])
|
||||
self.assertEqual(launcher.workdir, binary.resolve().parent)
|
||||
self.assertEqual(launcher.kind, "binary")
|
||||
|
||||
def test_defaults_to_bundled_binary_when_available(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
binary = Path(temp_dir) / "minio.windows-amd64.RELEASE.2025-09-07T16-13-09Z.exe"
|
||||
binary.write_text("", encoding="utf-8")
|
||||
|
||||
original_default_binary = lab.DEFAULT_MINIO_BINARY
|
||||
try:
|
||||
lab.DEFAULT_MINIO_BINARY = binary
|
||||
launcher = lab.discover_minio_launcher(explicit_binary=None, minio_root=None)
|
||||
finally:
|
||||
lab.DEFAULT_MINIO_BINARY = original_default_binary
|
||||
|
||||
self.assertEqual(launcher.command, [str(binary.resolve())])
|
||||
self.assertEqual(launcher.workdir, binary.resolve().parent)
|
||||
self.assertEqual(launcher.kind, "binary")
|
||||
|
||||
def test_rejects_source_checkout_without_binary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
source_root = Path(temp_dir) / "minio-master"
|
||||
source_root.mkdir()
|
||||
(source_root / "main.go").write_text("package main\n", encoding="utf-8")
|
||||
|
||||
original_default_binary = lab.DEFAULT_MINIO_BINARY
|
||||
try:
|
||||
lab.DEFAULT_MINIO_BINARY = Path(temp_dir) / "missing-bundled-binary.exe"
|
||||
with mock.patch.object(lab.shutil, "which", return_value=None):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
lab.discover_minio_launcher(explicit_binary=None, minio_root=source_root)
|
||||
finally:
|
||||
lab.DEFAULT_MINIO_BINARY = original_default_binary
|
||||
|
||||
def test_uses_binary_from_minio_root_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
binary_root = Path(temp_dir) / "minio-dist"
|
||||
binary_root.mkdir()
|
||||
binary = binary_root / "minio.exe"
|
||||
binary.write_text("", encoding="utf-8")
|
||||
|
||||
launcher = lab.discover_minio_launcher(explicit_binary=None, minio_root=binary_root)
|
||||
|
||||
self.assertEqual(launcher.command, [str(binary.resolve())])
|
||||
self.assertEqual(launcher.workdir, binary.resolve().parent)
|
||||
self.assertEqual(launcher.kind, "binary")
|
||||
|
||||
|
||||
class FixtureMatrixTests(unittest.TestCase):
|
||||
def test_default_matrix_covers_sse_modes_and_shapes(self) -> None:
|
||||
cases = lab.build_default_cases()
|
||||
case_ids = {case.case_id for case in cases}
|
||||
|
||||
self.assertEqual(len(cases), 6)
|
||||
self.assertIn("sse-s3-singlepart-64k", case_ids)
|
||||
self.assertIn("sse-kms-singlepart-64k", case_ids)
|
||||
self.assertIn("sse-c-singlepart-64k", case_ids)
|
||||
self.assertIn("sse-s3-multipart-8m", case_ids)
|
||||
self.assertIn("sse-kms-multipart-8m", case_ids)
|
||||
self.assertIn("sse-c-multipart-8m", case_ids)
|
||||
|
||||
def test_default_matrix_uses_configured_kms_key_id(self) -> None:
|
||||
cases = lab.build_default_cases(kms_key_id="local-fixture-key")
|
||||
kms_cases = [case for case in cases if case.encryption == "SSE-KMS"]
|
||||
|
||||
self.assertEqual(len(kms_cases), 2)
|
||||
self.assertTrue(all(case.kms_key_id == "local-fixture-key" for case in kms_cases))
|
||||
|
||||
|
||||
class RequestRecordTests(unittest.TestCase):
|
||||
def test_sse_kms_request_record_includes_context_header(self) -> None:
|
||||
case = lab.FixtureCase(
|
||||
case_id="sse-kms-singlepart-64k",
|
||||
bucket="demo",
|
||||
object_name="dir/object.bin",
|
||||
encryption="SSE-KMS",
|
||||
size_bytes=64 * 1024,
|
||||
multipart=False,
|
||||
kms_key_id="minio-default-key",
|
||||
kms_context={"project": "rio-v2", "stage": "fixture"},
|
||||
)
|
||||
|
||||
record = lab.build_request_record(case)
|
||||
|
||||
self.assertEqual(record["headers"]["x-amz-server-side-encryption"], "aws:kms")
|
||||
self.assertEqual(
|
||||
record["headers"]["x-amz-server-side-encryption-aws-kms-key-id"],
|
||||
"minio-default-key",
|
||||
)
|
||||
expected_context = base64.b64encode(
|
||||
b'{"project":"rio-v2","stage":"fixture"}'
|
||||
).decode("ascii")
|
||||
self.assertEqual(
|
||||
record["headers"]["x-amz-server-side-encryption-context"],
|
||||
expected_context,
|
||||
)
|
||||
|
||||
def test_sse_s3_request_record_uses_aes256(self) -> None:
|
||||
case = lab.FixtureCase(
|
||||
case_id="sse-s3-singlepart-64k",
|
||||
bucket="demo",
|
||||
object_name="dir/object.bin",
|
||||
encryption="SSE-S3",
|
||||
size_bytes=64 * 1024,
|
||||
multipart=False,
|
||||
)
|
||||
|
||||
record = lab.build_request_record(case)
|
||||
|
||||
self.assertEqual(record["headers"], {"x-amz-server-side-encryption": "AES256"})
|
||||
|
||||
def test_sse_c_request_record_includes_customer_key_headers(self) -> None:
|
||||
case = lab.FixtureCase(
|
||||
case_id="sse-c-singlepart-64k",
|
||||
bucket="demo",
|
||||
object_name="dir/object.bin",
|
||||
encryption="SSE-C",
|
||||
size_bytes=64 * 1024,
|
||||
multipart=False,
|
||||
)
|
||||
|
||||
record = lab.build_request_record(case)
|
||||
|
||||
self.assertEqual(
|
||||
record["headers"]["x-amz-server-side-encryption-customer-algorithm"],
|
||||
"AES256",
|
||||
)
|
||||
self.assertIn("x-amz-server-side-encryption-customer-key", record["headers"])
|
||||
self.assertIn("x-amz-server-side-encryption-customer-key-md5", record["headers"])
|
||||
|
||||
|
||||
class MultipartManifestTests(unittest.TestCase):
|
||||
def test_complete_multipart_payload_is_xml(self) -> None:
|
||||
parts = [
|
||||
{"ETag": '"etag-1"', "PartNumber": 1},
|
||||
{"ETag": '"etag-2"', "PartNumber": 2},
|
||||
]
|
||||
|
||||
payload = lab.build_complete_multipart_xml(parts).decode("utf-8")
|
||||
|
||||
self.assertIn("<CompleteMultipartUpload>", payload)
|
||||
self.assertIn("<PartNumber>1</PartNumber>", payload)
|
||||
self.assertIn("<ETag>\"etag-1\"</ETag>", payload)
|
||||
self.assertIn("<PartNumber>2</PartNumber>", payload)
|
||||
self.assertIn("<ETag>\"etag-2\"</ETag>", payload)
|
||||
|
||||
|
||||
class KmsSecretKeyTests(unittest.TestCase):
|
||||
def test_parse_kms_secret_key_extracts_key_id(self) -> None:
|
||||
setting = lab.parse_kms_secret_key("local-fixture-key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
|
||||
self.assertEqual(setting.secret_key, "local-fixture-key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
self.assertEqual(setting.key_id, "local-fixture-key")
|
||||
|
||||
def test_parse_kms_secret_key_rejects_missing_separator(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
lab.parse_kms_secret_key("invalid-secret-key")
|
||||
|
||||
def test_minio_env_prefers_explicit_secret_key(self) -> None:
|
||||
env = lab.minio_env(
|
||||
kms_secret_key=lab.parse_kms_secret_key(
|
||||
"local-fixture-key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
env["MINIO_KMS_SECRET_KEY"],
|
||||
"local-fixture-key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RequestRecord {
|
||||
encryption: String,
|
||||
headers: HashMap<String, String>,
|
||||
multipart: bool,
|
||||
size_bytes: u64,
|
||||
object: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HeadRecord {
|
||||
#[serde(rename = "ContentLength")]
|
||||
content_length: u64,
|
||||
#[serde(rename = "ServerSideEncryption")]
|
||||
server_side_encryption: Option<String>,
|
||||
#[serde(rename = "SSECustomerAlgorithm")]
|
||||
sse_customer_algorithm: Option<String>,
|
||||
#[serde(rename = "SSECustomerKeyMD5")]
|
||||
sse_customer_key_md5: Option<String>,
|
||||
#[serde(rename = "SSEKMSKeyId")]
|
||||
sse_kms_key_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManifestRecord {
|
||||
capture: Option<CaptureRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CaptureRecord {
|
||||
kms_key_id: Option<String>,
|
||||
}
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/minio-generated"))
|
||||
}
|
||||
|
||||
fn case_dir(case_id: &str) -> PathBuf {
|
||||
fixture_root().join("cases").join(case_id)
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> T {
|
||||
let text = fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
|
||||
serde_json::from_str(&text).unwrap_or_else(|err| panic!("parse {}: {err}", path.display()))
|
||||
}
|
||||
|
||||
fn find_object_xl_meta(case_dir: &Path) -> PathBuf {
|
||||
let backend = case_dir.join("backend");
|
||||
for entry in walkdir::WalkDir::new(&backend) {
|
||||
let entry = entry.unwrap_or_else(|err| panic!("walk {}: {err}", backend.display()));
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
if entry.file_name() != "xl.meta" {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let path_text = path.to_string_lossy();
|
||||
if path_text.contains(".minio.sys") {
|
||||
continue;
|
||||
}
|
||||
return path.to_path_buf();
|
||||
}
|
||||
panic!("object xl.meta not found under {}", backend.display());
|
||||
}
|
||||
|
||||
fn load_file_info(case_id: &str) -> FileInfo {
|
||||
let case = case_dir(case_id);
|
||||
let xl_meta = find_object_xl_meta(&case);
|
||||
let data = fs::read(&xl_meta).unwrap_or_else(|err| panic!("read {}: {err}", xl_meta.display()));
|
||||
get_file_info(
|
||||
&data,
|
||||
"fixture-bucket",
|
||||
"fixture-object",
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: true,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("decode {}: {err}", xl_meta.display()))
|
||||
}
|
||||
|
||||
fn read_manifest(case_id: &str) -> ManifestRecord {
|
||||
read_json(&case_dir(case_id).join("manifest.json"))
|
||||
}
|
||||
|
||||
fn require_generated_fixture_root() {
|
||||
let root = fixture_root();
|
||||
assert!(
|
||||
root.join("cases").is_dir(),
|
||||
"generated fixture root missing: {}. Run test/minio_fixture_lab/lab.py capture-matrix first.",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn metadata_keys(fi: &FileInfo) -> HashMap<&str, &str> {
|
||||
fi.metadata.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
|
||||
}
|
||||
|
||||
fn expected_fixture_kms_key_id(case_id: &str, request: &RequestRecord) -> Option<String> {
|
||||
request
|
||||
.headers
|
||||
.get("x-amz-server-side-encryption-aws-kms-key-id")
|
||||
.cloned()
|
||||
.or_else(|| read_manifest(case_id).capture.and_then(|capture| capture.kms_key_id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_singlepart_sse_s3_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let request: RequestRecord = read_json(&case_dir("sse-s3-singlepart-64k").join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir("sse-s3-singlepart-64k").join("head.json"));
|
||||
let fi = load_file_info("sse-s3-singlepart-64k");
|
||||
let metadata = metadata_keys(&fi);
|
||||
|
||||
assert_eq!(request.encryption, "SSE-S3");
|
||||
assert!(!request.multipart);
|
||||
assert_eq!(request.object, "dir/object.bin");
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.server_side_encryption.as_deref(), Some("AES256"));
|
||||
assert_eq!(fi.parts.len(), 1);
|
||||
assert_eq!(fi.parts[0].actual_size as u64, request.size_bytes);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key"));
|
||||
assert_eq!(metadata.get("content-type"), Some(&"binary/octet-stream"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_singlepart_sse_kms_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let case_id = "sse-kms-singlepart-64k";
|
||||
let request: RequestRecord = read_json(&case_dir(case_id).join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir(case_id).join("head.json"));
|
||||
let fi = load_file_info(case_id);
|
||||
let metadata = metadata_keys(&fi);
|
||||
let expected_key_id = expected_fixture_kms_key_id(case_id, &request).expect("fixture kms key id");
|
||||
|
||||
assert_eq!(request.encryption, "SSE-KMS");
|
||||
assert!(!request.multipart);
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.server_side_encryption.as_deref(), Some("aws:kms"));
|
||||
assert_eq!(request.headers.get("x-amz-server-side-encryption"), Some(&"aws:kms".to_string()));
|
||||
assert_eq!(request.headers.get("x-amz-server-side-encryption-aws-kms-key-id"), Some(&expected_key_id));
|
||||
assert_eq!(head.sse_kms_key_id.as_deref(), Some(format!("arn:aws:kms:{expected_key_id}").as_str()));
|
||||
assert_eq!(fi.parts.len(), 1);
|
||||
assert_eq!(fi.parts[0].actual_size as u64, request.size_bytes);
|
||||
assert_eq!(
|
||||
metadata.get("X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id"),
|
||||
Some(&expected_key_id.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("X-Minio-Internal-Server-Side-Encryption-Context").copied(),
|
||||
request
|
||||
.headers
|
||||
.get("x-amz-server-side-encryption-context")
|
||||
.map(String::as_str)
|
||||
);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_multipart_sse_s3_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let case_id = "sse-s3-multipart-8m";
|
||||
let request: RequestRecord = read_json(&case_dir(case_id).join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir(case_id).join("head.json"));
|
||||
let fi = load_file_info(case_id);
|
||||
let metadata = metadata_keys(&fi);
|
||||
|
||||
assert_eq!(request.encryption, "SSE-S3");
|
||||
assert!(request.multipart);
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.server_side_encryption.as_deref(), Some("AES256"));
|
||||
assert_eq!(fi.parts.len(), 2);
|
||||
assert_eq!(fi.parts.iter().map(|part| part.actual_size as u64).sum::<u64>(), request.size_bytes);
|
||||
assert!(
|
||||
metadata
|
||||
.get("X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id")
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key"));
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Encrypted-Multipart"));
|
||||
assert_eq!(metadata.get("X-Minio-Internal-actual-size"), Some(&"8388608"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_multipart_sse_kms_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let case_id = "sse-kms-multipart-8m";
|
||||
let request: RequestRecord = read_json(&case_dir(case_id).join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir(case_id).join("head.json"));
|
||||
let fi = load_file_info(case_id);
|
||||
let metadata = metadata_keys(&fi);
|
||||
let expected_key_id = expected_fixture_kms_key_id(case_id, &request).expect("fixture kms key id");
|
||||
|
||||
assert_eq!(request.encryption, "SSE-KMS");
|
||||
assert!(request.multipart);
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.server_side_encryption.as_deref(), Some("aws:kms"));
|
||||
assert_eq!(request.headers.get("x-amz-server-side-encryption-aws-kms-key-id"), Some(&expected_key_id));
|
||||
assert_eq!(head.sse_kms_key_id.as_deref(), Some(format!("arn:aws:kms:{expected_key_id}").as_str()));
|
||||
assert_eq!(fi.parts.len(), 2);
|
||||
assert_eq!(fi.parts.iter().map(|part| part.actual_size as u64).sum::<u64>(), request.size_bytes);
|
||||
assert_eq!(
|
||||
metadata.get("X-Minio-Internal-Server-Side-Encryption-Context").copied(),
|
||||
request
|
||||
.headers
|
||||
.get("x-amz-server-side-encryption-context")
|
||||
.map(String::as_str)
|
||||
);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Encrypted-Multipart"));
|
||||
assert_eq!(metadata.get("X-Minio-Internal-actual-size"), Some(&"8388608"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_singlepart_sse_c_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let case_id = "sse-c-singlepart-64k";
|
||||
let request: RequestRecord = read_json(&case_dir(case_id).join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir(case_id).join("head.json"));
|
||||
let fi = load_file_info(case_id);
|
||||
let metadata = metadata_keys(&fi);
|
||||
|
||||
assert_eq!(request.encryption, "SSE-C");
|
||||
assert!(!request.multipart);
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.sse_customer_algorithm.as_deref(), Some("AES256"));
|
||||
assert_eq!(
|
||||
head.sse_customer_key_md5.as_deref(),
|
||||
request
|
||||
.headers
|
||||
.get("x-amz-server-side-encryption-customer-key-md5")
|
||||
.map(String::as_str)
|
||||
);
|
||||
assert_eq!(fi.parts.len(), 1);
|
||||
assert_eq!(fi.parts[0].actual_size as u64, request.size_bytes);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-Sealed-Key"));
|
||||
assert!(!metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires locally generated MinIO fixtures"]
|
||||
fn parses_multipart_sse_c_fixture() {
|
||||
require_generated_fixture_root();
|
||||
|
||||
let case_id = "sse-c-multipart-8m";
|
||||
let request: RequestRecord = read_json(&case_dir(case_id).join("request.json"));
|
||||
let head: HeadRecord = read_json(&case_dir(case_id).join("head.json"));
|
||||
let fi = load_file_info(case_id);
|
||||
let metadata = metadata_keys(&fi);
|
||||
|
||||
assert_eq!(request.encryption, "SSE-C");
|
||||
assert!(request.multipart);
|
||||
assert_eq!(head.content_length, request.size_bytes);
|
||||
assert_eq!(head.sse_customer_algorithm.as_deref(), Some("AES256"));
|
||||
assert_eq!(
|
||||
head.sse_customer_key_md5.as_deref(),
|
||||
request
|
||||
.headers
|
||||
.get("x-amz-server-side-encryption-customer-key-md5")
|
||||
.map(String::as_str)
|
||||
);
|
||||
assert_eq!(fi.parts.len(), 2);
|
||||
assert_eq!(fi.parts.iter().map(|part| part.actual_size as u64).sum::<u64>(), request.size_bytes);
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-Sealed-Key"));
|
||||
assert!(!metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id"));
|
||||
assert!(metadata.contains_key("X-Minio-Internal-Encrypted-Multipart"));
|
||||
assert_eq!(metadata.get("X-Minio-Internal-actual-size"), Some(&"8388608"));
|
||||
}
|
||||
Reference in New Issue
Block a user