1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
10use std::fmt::Write as _;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use base64::Engine as _;
15use base64::engine::general_purpose::STANDARD as BASE64_STD;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use uselesskey_core::{Factory, Seed};
19#[cfg(feature = "rsa-materialize")]
20use uselesskey_rsa::{RsaFactoryExt, RsaSpec};
21use uselesskey_token::{TokenFactoryExt, TokenSpec};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct BundleManifest {
26 pub schema_version: u32,
28 pub artifacts: Vec<ManifestArtifact>,
30}
31
32impl BundleManifest {
33 pub fn new() -> Self {
35 Self {
36 schema_version: 1,
37 artifacts: Vec::new(),
38 }
39 }
40
41 pub fn with_artifact(mut self, artifact: ManifestArtifact) -> Self {
43 self.artifacts.push(artifact);
44 self
45 }
46
47 pub fn to_pretty_json(&self) -> Result<String, BundleError> {
49 serde_json::to_string_pretty(self).map_err(BundleError::from)
50 }
51
52 pub fn write_json<P: AsRef<Path>>(&self, path: P) -> Result<(), BundleError> {
54 let path = path.as_ref();
55 if let Some(parent) = path.parent() {
56 fs::create_dir_all(parent)?;
57 }
58 fs::write(path, self.to_pretty_json()?)?;
59 Ok(())
60 }
61}
62
63impl Default for BundleManifest {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ManifestArtifact {
72 pub artifact_type: ArtifactType,
73 pub source_seed: Option<String>,
74 pub source_label: String,
75 pub output_paths: Vec<String>,
76 pub fingerprints: Vec<Fingerprint>,
77 pub env_var_names: Vec<String>,
78 pub external_key_ref: Option<KeyRef>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "kind", rename_all = "snake_case")]
84pub enum KeyRef {
85 File { path: String },
86 Env { var: String },
87 Vault { path: String },
88 AwsSecret { name: String },
89 GcpSecret { name: String },
90 K8sSecret { name: String, key: String },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ArtifactType {
97 RsaPkcs8Pem,
98 SpkiPem,
99 Jwk,
100 Token,
101 X509Pem,
102 Opaque,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Fingerprint {
108 pub algorithm: String,
109 pub value: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ExportArtifact {
115 pub key: String,
116 pub value: String,
117 pub manifest: ManifestArtifact,
118}
119
120#[derive(Debug, Error)]
122pub enum BundleError {
123 #[error("I/O error: {0}")]
124 Io(#[from] std::io::Error),
125 #[error("JSON error: {0}")]
126 Json(#[from] serde_json::Error),
127}
128
129pub const MATERIALIZE_MANIFEST_VERSION: u32 = 1;
131
132#[derive(Debug, Error)]
134pub enum MaterializeError {
135 #[error("I/O error: {0}")]
136 Io(#[from] std::io::Error),
137 #[error("JSON error: {0}")]
138 Json(#[from] serde_json::Error),
139 #[error("manifest parse error: {0}")]
140 Toml(#[from] toml::de::Error),
141 #[error("{0}")]
142 InvalidManifest(String),
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct MaterializeManifest {
148 #[serde(default)]
149 pub version: Option<u32>,
150 #[serde(default, alias = "fixture")]
151 pub fixtures: Vec<MaterializeFixtureSpec>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct MaterializeFixtureSpec {
157 #[serde(default)]
158 pub id: Option<String>,
159 #[serde(alias = "path")]
160 pub out: PathBuf,
161 pub kind: MaterializeKind,
162 pub seed: String,
163 #[serde(default)]
164 pub label: Option<String>,
165 #[serde(default)]
166 pub len: Option<usize>,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171pub enum MaterializeKind {
172 #[serde(rename = "entropy.bytes", alias = "entropy_bytes")]
173 EntropyBytes,
174 #[serde(rename = "token.jwt_shape", alias = "jwt_shape")]
175 TokenJwtShape,
176 #[serde(rename = "rsa.pkcs8_der", alias = "pkcs8_der")]
177 RsaPkcs8Der,
178 #[serde(rename = "rsa.pkcs8_pem", alias = "pkcs8_pem")]
179 RsaPkcs8Pem,
180 #[serde(rename = "pem.block_shape", alias = "pem_block_shape")]
181 PemBlockShape,
182 #[serde(rename = "ssh.public_key_shape", alias = "ssh_public_key_shape")]
183 SshPublicKeyShape,
184 #[serde(rename = "token.api_key", alias = "token")]
185 TokenApiKey,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190pub struct MaterializeSummary {
191 pub count: usize,
192 pub files: Vec<PathBuf>,
193}
194
195pub fn parse_materialize_manifest_str(raw: &str) -> Result<MaterializeManifest, MaterializeError> {
197 let manifest: MaterializeManifest = toml::from_str(raw)?;
198 validate_materialize_manifest(manifest)
199}
200
201pub fn load_materialize_manifest(path: &Path) -> Result<MaterializeManifest, MaterializeError> {
203 let raw = fs::read_to_string(path)?;
204 parse_materialize_manifest_str(&raw)
205}
206
207pub fn materialize_manifest_to_dir(
209 manifest: &MaterializeManifest,
210 out_dir: &Path,
211 check: bool,
212) -> Result<MaterializeSummary, MaterializeError> {
213 if manifest.fixtures.is_empty() {
214 return Err(MaterializeError::InvalidManifest(
215 "materialize manifest has no fixtures".to_string(),
216 ));
217 }
218
219 let mut files = Vec::with_capacity(manifest.fixtures.len());
220 for fixture in &manifest.fixtures {
221 let out_path = resolve_fixture_path(out_dir, &fixture.out);
222 let bytes = materialized_fixture_bytes(fixture)?;
223 if check {
224 verify_fixture_bytes(&out_path, &bytes)?;
225 } else {
226 if let Some(parent) = out_path.parent() {
227 fs::create_dir_all(parent)?;
228 }
229 fs::write(&out_path, bytes)?;
230 }
231 files.push(out_path);
232 }
233
234 Ok(MaterializeSummary {
235 count: manifest.fixtures.len(),
236 files,
237 })
238}
239
240pub fn materialize_manifest_file(
242 manifest_path: &Path,
243 out_dir: &Path,
244 check: bool,
245) -> Result<MaterializeSummary, MaterializeError> {
246 let manifest = load_materialize_manifest(manifest_path)?;
247 materialize_manifest_to_dir(&manifest, out_dir, check)
248}
249
250pub fn emit_include_bytes_module(
252 manifest: &MaterializeManifest,
253 out_dir: &Path,
254 module_path: &Path,
255) -> Result<(), MaterializeError> {
256 if manifest.fixtures.is_empty() {
257 return Err(MaterializeError::InvalidManifest(
258 "cannot emit module for empty materialize manifest".to_string(),
259 ));
260 }
261
262 let mut out = String::from("// @generated by uselesskey-cli materialize\n");
263 let mut seen = std::collections::BTreeSet::new();
264 for fixture in &manifest.fixtures {
265 let const_name = fixture_const_name(fixture);
266 if !seen.insert(const_name.clone()) {
267 return Err(MaterializeError::InvalidManifest(format!(
268 "duplicate emitted constant name `{const_name}`"
269 )));
270 }
271
272 let include_path = resolve_fixture_path(out_dir, &fixture.out);
273 let escaped = include_path
274 .display()
275 .to_string()
276 .replace('\\', "\\\\")
277 .replace('"', "\\\"");
278 let _ = writeln!(
279 &mut out,
280 "pub const {const_name}: &[u8] = include_bytes!(\"{escaped}\");"
281 );
282 }
283
284 if let Some(parent) = module_path.parent() {
285 fs::create_dir_all(parent)?;
286 }
287 fs::write(module_path, out)?;
288 Ok(())
289}
290
291pub fn export_flat_files<P: AsRef<Path>>(
293 root: P,
294 artifacts: &[ExportArtifact],
295) -> Result<Vec<PathBuf>, BundleError> {
296 let root = root.as_ref();
297 fs::create_dir_all(root)?;
298
299 let mut written = Vec::with_capacity(artifacts.len());
300 for artifact in artifacts {
301 let path = root.join(&artifact.key);
302 fs::write(&path, artifact.value.as_bytes())?;
303 written.push(path);
304 }
305 Ok(written)
306}
307
308pub fn export_envdir<P: AsRef<Path>>(
310 root: P,
311 artifacts: &[ExportArtifact],
312) -> Result<Vec<PathBuf>, BundleError> {
313 let root = root.as_ref();
314 fs::create_dir_all(root)?;
315
316 let mut written = Vec::new();
317 for artifact in artifacts {
318 for var in &artifact.manifest.env_var_names {
319 let path = root.join(var);
320 fs::write(&path, artifact.value.as_bytes())?;
321 written.push(path);
322 }
323 }
324 Ok(written)
325}
326
327pub fn render_dotenv_fragment(artifacts: &[ExportArtifact]) -> String {
329 let mut out = String::new();
330 for artifact in artifacts {
331 if let Some(var) = artifact.manifest.env_var_names.first() {
332 let escaped = artifact
333 .value
334 .replace('\\', "\\\\")
335 .replace('\n', "\\n")
336 .replace('"', "\\\"");
337 let _ = writeln!(&mut out, "{var}=\"{escaped}\"");
338 }
339 }
340 out
341}
342
343pub fn render_k8s_secret_yaml(
345 secret_name: &str,
346 namespace: Option<&str>,
347 artifacts: &[ExportArtifact],
348) -> String {
349 let mut out = String::new();
350 let _ = writeln!(&mut out, "apiVersion: v1");
351 let _ = writeln!(&mut out, "kind: Secret");
352 let _ = writeln!(&mut out, "metadata:");
353 let _ = writeln!(&mut out, " name: {secret_name}");
354 if let Some(ns) = namespace {
355 let _ = writeln!(&mut out, " namespace: {ns}");
356 }
357 let _ = writeln!(&mut out, "type: Opaque");
358 let _ = writeln!(&mut out, "data:");
359 for artifact in artifacts {
360 let encoded = BASE64_STD.encode(artifact.value.as_bytes());
361 let _ = writeln!(&mut out, " {}: {}", artifact.key, encoded);
362 }
363 out
364}
365
366pub fn render_sops_ready_yaml(artifacts: &[ExportArtifact]) -> String {
368 let mut out = String::new();
369 for artifact in artifacts {
370 let _ = writeln!(
371 &mut out,
372 "{}: ENC[AES256_GCM,data:REDACTED,type:str]",
373 artifact.key
374 );
375 }
376 let _ = writeln!(&mut out, "sops:");
377 let _ = writeln!(&mut out, " version: 3.9.0");
378 let _ = writeln!(&mut out, " mac: ENC[AES256_GCM,data:REDACTED,type:str]");
379 out
380}
381
382pub fn render_vault_kv_json(artifacts: &[ExportArtifact]) -> Result<String, BundleError> {
384 #[derive(Serialize)]
385 struct VaultPayload<'a> {
386 data: BTreeMap<&'a str, &'a str>,
387 metadata: BTreeMap<&'a str, &'a str>,
388 }
389
390 let data = artifacts
391 .iter()
392 .map(|a| (a.key.as_str(), a.value.as_str()))
393 .collect::<BTreeMap<_, _>>();
394
395 let metadata = [("source", "uselesskey-cli"), ("mode", "one_shot_export")]
396 .into_iter()
397 .collect::<BTreeMap<_, _>>();
398
399 serde_json::to_string_pretty(&VaultPayload { data, metadata }).map_err(BundleError::from)
400}
401
402fn validate_materialize_manifest(
403 manifest: MaterializeManifest,
404) -> Result<MaterializeManifest, MaterializeError> {
405 let version = manifest.version.unwrap_or(MATERIALIZE_MANIFEST_VERSION);
406 if version != MATERIALIZE_MANIFEST_VERSION {
407 return Err(MaterializeError::InvalidManifest(format!(
408 "unsupported manifest version {version}"
409 )));
410 }
411 Ok(manifest)
412}
413
414fn resolve_fixture_path(out_dir: &Path, target: &Path) -> PathBuf {
415 if target.is_absolute() {
416 target.to_path_buf()
417 } else {
418 out_dir.join(target)
419 }
420}
421
422fn materialized_fixture_bytes(spec: &MaterializeFixtureSpec) -> Result<Vec<u8>, MaterializeError> {
423 let label = spec
424 .label
425 .clone()
426 .unwrap_or_else(|| fallback_label(&spec.out));
427 let fx = Factory::deterministic_from_str(&spec.seed);
428
429 match spec.kind {
430 MaterializeKind::EntropyBytes => {
431 let len = spec.len.unwrap_or(32);
432 let seed = Seed::from_text(&spec.seed);
433 let mut bytes = vec![0u8; len];
434 seed.fill_bytes(&mut bytes);
435 Ok(bytes)
436 }
437 MaterializeKind::TokenJwtShape => Ok(fx
438 .token(&label, TokenSpec::oauth_access_token())
439 .value()
440 .as_bytes()
441 .to_vec()),
442 MaterializeKind::RsaPkcs8Der => {
443 #[cfg(feature = "rsa-materialize")]
444 {
445 Ok(fx
446 .rsa(&label, RsaSpec::rs256())
447 .private_key_pkcs8_der()
448 .to_vec())
449 }
450 #[cfg(not(feature = "rsa-materialize"))]
451 {
452 Err(MaterializeError::InvalidManifest(
453 "rsa.pkcs8_der requires uselesskey-cli feature `rsa-materialize`".to_string(),
454 ))
455 }
456 }
457 MaterializeKind::RsaPkcs8Pem => {
458 #[cfg(feature = "rsa-materialize")]
459 {
460 Ok(fx
461 .rsa(&label, RsaSpec::rs256())
462 .private_key_pkcs8_pem()
463 .as_bytes()
464 .to_vec())
465 }
466 #[cfg(not(feature = "rsa-materialize"))]
467 {
468 Err(MaterializeError::InvalidManifest(
469 "rsa.pkcs8_pem requires uselesskey-cli feature `rsa-materialize`".to_string(),
470 ))
471 }
472 }
473 MaterializeKind::PemBlockShape => {
474 let len = spec.len.unwrap_or(256);
475 let seed = Seed::from_text(&spec.seed);
476 let mut bytes = vec![0u8; len];
477 seed.fill_bytes(&mut bytes);
478 let payload = BASE64_STD.encode(bytes);
479 let block_label = normalize_pem_label(&label);
480 let mut out = String::new();
481 let _ = writeln!(&mut out, "-----BEGIN {block_label}-----");
482 for chunk in payload.as_bytes().chunks(64) {
483 let _ = writeln!(
484 &mut out,
485 "{}",
486 std::str::from_utf8(chunk).map_err(|err| {
487 MaterializeError::InvalidManifest(format!(
488 "generated base64 payload was not utf-8: {err}"
489 ))
490 })?
491 );
492 }
493 let _ = writeln!(&mut out, "-----END {block_label}-----");
494 Ok(out.into_bytes())
495 }
496 MaterializeKind::SshPublicKeyShape => {
497 let seed = Seed::from_text(&spec.seed);
498 let mut bytes = [0u8; 32];
499 seed.fill_bytes(&mut bytes);
500 Ok(format!(
501 "ssh-ed25519 {} {}\n",
502 BASE64_STD.encode(bytes),
503 normalize_ssh_comment(&label)
504 )
505 .into_bytes())
506 }
507 MaterializeKind::TokenApiKey => Ok(fx
508 .token(&label, TokenSpec::api_key())
509 .value()
510 .as_bytes()
511 .to_vec()),
512 }
513}
514
515fn verify_fixture_bytes(path: &Path, expected: &[u8]) -> Result<(), MaterializeError> {
516 let actual = fs::read(path)?;
517 if actual != expected {
518 return Err(MaterializeError::InvalidManifest(format!(
519 "materialize check failed: {} content mismatch",
520 path.display()
521 )));
522 }
523 Ok(())
524}
525
526fn fallback_label(path: &Path) -> String {
527 path.file_stem()
528 .and_then(|stem| stem.to_str())
529 .unwrap_or("fixture")
530 .chars()
531 .map(|ch| {
532 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
533 ch
534 } else {
535 '_'
536 }
537 })
538 .collect()
539}
540
541fn normalize_pem_label(label: &str) -> String {
542 let normalized: String = label
543 .chars()
544 .map(|ch| {
545 if ch.is_ascii_alphanumeric() {
546 ch.to_ascii_uppercase()
547 } else {
548 '_'
549 }
550 })
551 .collect();
552 if normalized.is_empty() {
553 "SECRET".to_string()
554 } else {
555 normalized
556 }
557}
558
559fn normalize_ssh_comment(label: &str) -> String {
560 let normalized: String = label
561 .chars()
562 .map(|ch| {
563 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' {
564 ch
565 } else {
566 '-'
567 }
568 })
569 .collect();
570 if normalized.is_empty() {
571 "fixture".to_string()
572 } else {
573 normalized
574 }
575}
576
577fn fixture_const_name(spec: &MaterializeFixtureSpec) -> String {
578 let base = spec.id.clone().unwrap_or_else(|| fallback_label(&spec.out));
579 let mut out = String::with_capacity(base.len());
580 for ch in base.chars() {
581 if ch.is_ascii_alphanumeric() {
582 out.push(ch.to_ascii_uppercase());
583 } else {
584 out.push('_');
585 }
586 }
587 if out.is_empty() || out.as_bytes()[0].is_ascii_digit() {
588 out.insert(0, '_');
589 }
590 out
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 #[test]
598 fn dotenv_escapes_special_characters() {
599 let artifacts = vec![ExportArtifact {
600 key: "issuer_pem".to_string(),
601 value: "line1\nline\"2".to_string(),
602 manifest: ManifestArtifact {
603 artifact_type: ArtifactType::RsaPkcs8Pem,
604 source_seed: Some("seed-a".to_string()),
605 source_label: "issuer".to_string(),
606 output_paths: vec![],
607 fingerprints: vec![],
608 env_var_names: vec!["ISSUER_PEM".to_string()],
609 external_key_ref: None,
610 },
611 }];
612
613 let rendered = render_dotenv_fragment(&artifacts);
614 assert_eq!(rendered, "ISSUER_PEM=\"line1\\nline\\\"2\"\n");
615 }
616
617 #[test]
618 fn materialize_manifest_accepts_singular_fixture_and_dot_kinds() {
619 let manifest = parse_materialize_manifest_str(
620 r#"
621version = 1
622
623[[fixture]]
624id = "entropy"
625kind = "entropy.bytes"
626seed = "seed-a"
627len = 16
628out = "entropy.bin"
629"#,
630 )
631 .expect("manifest should parse");
632
633 assert_eq!(manifest.fixtures.len(), 1);
634 assert_eq!(manifest.fixtures[0].id.as_deref(), Some("entropy"));
635 assert_eq!(manifest.fixtures[0].kind, MaterializeKind::EntropyBytes);
636 assert_eq!(manifest.fixtures[0].out, PathBuf::from("entropy.bin"));
637 }
638
639 #[test]
640 fn ssh_public_key_shape_stays_shape_only() {
641 let bytes = materialized_fixture_bytes(&MaterializeFixtureSpec {
642 id: Some("ssh-shape".to_string()),
643 out: PathBuf::from("id_ed25519.pub"),
644 kind: MaterializeKind::SshPublicKeyShape,
645 seed: "seed-a".to_string(),
646 label: Some("deploy@example".to_string()),
647 len: None,
648 })
649 .expect("ssh shape should render");
650 let rendered = String::from_utf8(bytes).expect("shape should be utf-8");
651 assert!(rendered.starts_with("ssh-ed25519 "));
652 assert!(rendered.ends_with(" deploy-example\n"));
653 }
654
655 #[cfg(not(feature = "rsa-materialize"))]
656 #[test]
657 fn rsa_materialize_requires_feature() {
658 let error = materialized_fixture_bytes(&MaterializeFixtureSpec {
659 id: Some("rsa".to_string()),
660 out: PathBuf::from("private-key.pk8"),
661 kind: MaterializeKind::RsaPkcs8Der,
662 seed: "seed-a".to_string(),
663 label: Some("issuer".to_string()),
664 len: None,
665 })
666 .expect_err("rsa materialize should require feature");
667 assert!(
668 error
669 .to_string()
670 .contains("rsa.pkcs8_der requires uselesskey-cli feature `rsa-materialize`")
671 );
672 }
673}