Skip to main content

uselesskey_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Export/bundle helpers for `uselesskey` fixture handoff.
4//!
5//! This crate intentionally focuses on one-shot local export targets and metadata
6//! manifests. It does not implement rotation, retrieval, leasing, or long-running
7//! key-store behavior.
8
9use 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/// Bundle manifest describing generated artifacts and handoff metadata.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct BundleManifest {
26    /// Schema version for downstream compatibility.
27    pub schema_version: u32,
28    /// Artifact records in stable order.
29    pub artifacts: Vec<ManifestArtifact>,
30}
31
32impl BundleManifest {
33    /// Create an empty manifest with schema version `1`.
34    pub fn new() -> Self {
35        Self {
36            schema_version: 1,
37            artifacts: Vec::new(),
38        }
39    }
40
41    /// Add an artifact record and return self for chaining.
42    pub fn with_artifact(mut self, artifact: ManifestArtifact) -> Self {
43        self.artifacts.push(artifact);
44        self
45    }
46
47    /// Render the manifest as pretty JSON.
48    pub fn to_pretty_json(&self) -> Result<String, BundleError> {
49        serde_json::to_string_pretty(self).map_err(BundleError::from)
50    }
51
52    /// Persist the manifest as pretty JSON on disk.
53    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/// Per-artifact metadata in [`BundleManifest`].
70#[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/// Secret-key external reference model.
82#[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/// Artifact kinds for bundle metadata.
94#[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/// Cryptographic fingerprint metadata.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Fingerprint {
108    pub algorithm: String,
109    pub value: String,
110}
111
112/// In-memory artifact material and metadata used by exporters.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ExportArtifact {
115    pub key: String,
116    pub value: String,
117    pub manifest: ManifestArtifact,
118}
119
120/// Export errors.
121#[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
129/// Materialization manifest schema version supported by this crate.
130pub const MATERIALIZE_MANIFEST_VERSION: u32 = 1;
131
132/// Errors for manifest-driven fixture materialization.
133#[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/// Manifest describing deterministic fixture outputs that can be written or verified.
146#[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/// One fixture entry in a [`MaterializeManifest`].
155#[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/// Supported fixture kinds for manifest-driven materialization.
170#[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/// Summary returned after a materialize or verify pass.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190pub struct MaterializeSummary {
191    pub count: usize,
192    pub files: Vec<PathBuf>,
193}
194
195/// Parse a manifest string and validate the supported schema.
196pub 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
201/// Load and validate a manifest from disk.
202pub 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
207/// Materialize or verify the manifest contents under `out_dir`.
208pub 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
240/// Load a manifest from disk, then materialize or verify it under `out_dir`.
241pub 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
250/// Emit a Rust module containing `include_bytes!` constants for each manifest entry.
251pub 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
291/// Write a set of artifacts to `root/<key>` as flat files.
292pub 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
308/// Write artifacts as envdir files (`root/<ENV_VAR_NAME>` => value).
309pub 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
327/// Render dotenv fragment (`KEY="value"`) using the first env-var name per artifact.
328pub 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
343/// Render a Kubernetes Secret manifest (opaque string data encoded as base64 under `data`).
344pub 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
366/// Render a SOPS-ready YAML skeleton with encrypted placeholders and metadata section.
367pub 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
382/// Render a Vault KV-v2 JSON payload (`{"data":{...},"metadata":{...}}`).
383pub 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}