1
use std::fs;
2
use std::path::Path;
3

            
4
use clap::Args;
5
use mk_lib::file::ToUtf8 as _;
6
use mk_lib::secrets::{
7
  read_vault_gpg_key_id,
8
  write_vault_meta,
9
  SecretBackend,
10
  SecretConfig,
11
  SecretSettings,
12
  VaultMeta,
13
};
14
use serde_yaml::{
15
  Mapping,
16
  Value,
17
};
18

            
19
use crate::secrets::context::Context;
20

            
21
#[derive(Debug, Args)]
22
pub struct InitVault {
23
  #[arg(short, long, help = "The path to the secret vault")]
24
  vault_location: Option<String>,
25

            
26
  #[arg(short, long, help = "The key name", conflicts_with = "gpg_key_id")]
27
  key_name: Option<String>,
28

            
29
  #[arg(
30
    long,
31
    conflicts_with = "key_name",
32
    help = "GPG key ID or fingerprint to associate with this vault. When set, all vault commands (store, show, export, …) will use gpg automatically without needing the --gpg-key-id flag. Cannot be combined with --key-name."
33
  )]
34
  gpg_key_id: Option<String>,
35

            
36
  #[arg(
37
    long,
38
    help = "Update the active config file with a root secrets block after vault initialization"
39
  )]
40
  write_config: bool,
41
}
42

            
43
impl InitVault {
44
  pub fn execute(&self, context: &Context) -> anyhow::Result<()> {
45
    let mut secret_config = context.resolved_config();
46
    if let Some(vault_location) = &self.vault_location {
47
      secret_config.vault_location = Path::new(vault_location).to_path_buf();
48
    }
49
    let gpg_key_id = self
50
      .gpg_key_id
51
      .clone()
52
      .or_else(|| secret_config.gpg_key_id.clone());
53
    secret_config.backend = if gpg_key_id.is_some() {
54
      SecretBackend::Gpg
55
    } else {
56
      SecretBackend::BuiltInPgp
57
    };
58
    secret_config.gpg_key_id = gpg_key_id.clone();
59
    let vault_location = secret_config.vault_location.to_string_lossy().to_string();
60

            
61
    if self.write_config {
62
      ensure_config_mutation_supported(context.active_config_path())?;
63
    }
64

            
65
    assert!(!vault_location.is_empty(), "Vault location must be provided");
66

            
67
    let path = Path::new(&vault_location);
68
    if path.exists() {
69
      println!("Vault already exists at {vault_location}");
70
    } else {
71
      fs::create_dir_all(path)?;
72
      println!("Vault created at {vault_location}");
73
    }
74

            
75
    if let Some(id) = &gpg_key_id {
76
      if let Some(existing_id) = read_vault_gpg_key_id(path) {
77
        if existing_id != *id {
78
          eprintln!(
79
            "Warning: vault GPG key changed from '{}' to '{}'. Overwriting metadata.",
80
            existing_id, id
81
          );
82
        }
83
      }
84
      println!("Vault configured to use GPG key: {id}");
85
    }
86

            
87
    write_vault_meta(path, &vault_meta_from_config(&secret_config))?;
88

            
89
    if self.write_config {
90
      let config_path = context
91
        .active_config_path()
92
        .ok_or_else(|| anyhow::anyhow!("No active config file available for --write-config"))?;
93
      let secrets = secret_settings_for_config_write(context, &secret_config);
94
      update_yaml_config(config_path, &secrets)?;
95
      println!(
96
        "Config updated at {}",
97
        config_path.to_utf8().unwrap_or("<non-utf8-path>")
98
      );
99
    }
100

            
101
    Ok(())
102
  }
103
}
104

            
105
fn ensure_config_mutation_supported(config_path: Option<&Path>) -> anyhow::Result<()> {
106
  let Some(config_path) = config_path else {
107
    anyhow::bail!("No active config file available for --write-config");
108
  };
109

            
110
  match config_path.extension().and_then(|ext| ext.to_str()) {
111
    Some("yaml") | Some("yml") => Ok(()),
112
    Some(ext) => anyhow::bail!(
113
      "Config mutation via --write-config only supports YAML files. Refusing to update .{} config: {}",
114
      ext,
115
      config_path.to_utf8().unwrap_or("<non-utf8-path>")
116
    ),
117
    None => anyhow::bail!(
118
      "Config mutation via --write-config only supports YAML files. Refusing to update config without extension: {}",
119
      config_path.to_utf8().unwrap_or("<non-utf8-path>")
120
    ),
121
  }
122
}
123

            
124
fn vault_meta_from_config(secret_config: &SecretConfig) -> VaultMeta {
125
  match secret_config.backend {
126
    SecretBackend::Gpg => VaultMeta {
127
      backend: Some(SecretBackend::Gpg),
128
      keys_location: None,
129
      key_name: None,
130
      gpg_key_id: secret_config.gpg_key_id.clone(),
131
    },
132
    SecretBackend::BuiltInPgp => VaultMeta {
133
      backend: Some(SecretBackend::BuiltInPgp),
134
      keys_location: Some(secret_config.keys_location.to_string_lossy().to_string()),
135
      key_name: Some(secret_config.key_name.clone()),
136
      gpg_key_id: None,
137
    },
138
  }
139
}
140

            
141
fn secret_settings_for_config_write(context: &Context, secret_config: &SecretConfig) -> SecretSettings {
142
  let secrets_path = context
143
    .root_settings()
144
    .and_then(|settings| settings.secrets_path.clone());
145
  let vault_location = path_for_config(context.config_base_dir(), &secret_config.vault_location);
146

            
147
  match secret_config.backend {
148
    SecretBackend::Gpg => SecretSettings {
149
      backend: Some(SecretBackend::Gpg),
150
      vault_location: Some(vault_location),
151
      keys_location: None,
152
      key_name: None,
153
      gpg_key_id: secret_config.gpg_key_id.clone(),
154
      secrets_path,
155
    },
156
    SecretBackend::BuiltInPgp => SecretSettings {
157
      backend: Some(SecretBackend::BuiltInPgp),
158
      vault_location: Some(vault_location),
159
      keys_location: Some(path_for_config(
160
        context.config_base_dir(),
161
        &secret_config.keys_location,
162
      )),
163
      key_name: Some(secret_config.key_name.clone()),
164
      gpg_key_id: None,
165
      secrets_path,
166
    },
167
  }
168
}
169

            
170
fn path_for_config(base_dir: &Path, path: &Path) -> String {
171
  if let Ok(relative) = path.strip_prefix(base_dir) {
172
    let rendered = relative.to_string_lossy().replace('\\', "/");
173
    if !rendered.is_empty() {
174
      return rendered;
175
    }
176
  }
177

            
178
  if let Some(relative) = canonical_relative_path(base_dir, path) {
179
    let rendered = relative.to_string_lossy().replace('\\', "/");
180
    if !rendered.is_empty() {
181
      return rendered;
182
    }
183
  }
184

            
185
  path.to_string_lossy().replace('\\', "/")
186
}
187

            
188
fn canonical_relative_path(base_dir: &Path, path: &Path) -> Option<std::path::PathBuf> {
189
  let canonical_base = fs::canonicalize(base_dir).ok()?;
190
  let canonical_path = fs::canonicalize(path).ok()?;
191
  canonical_path
192
    .strip_prefix(canonical_base)
193
    .ok()
194
    .map(std::path::Path::to_path_buf)
195
}
196

            
197
fn update_yaml_config(config_path: &Path, secrets: &SecretSettings) -> anyhow::Result<()> {
198
  let (mut value, prefix) = if config_path.exists() {
199
    let contents = fs::read_to_string(config_path)?;
200
    let prefix = leading_yaml_preamble(&contents);
201
    let trimmed = contents.trim();
202
    let value = if trimmed.is_empty() {
203
      Value::Mapping(Mapping::new())
204
    } else {
205
      serde_yaml::from_str::<Value>(&contents)?
206
    };
207
    (value, prefix)
208
  } else {
209
    (Value::Mapping(Mapping::new()), String::new())
210
  };
211

            
212
  let mapping = value
213
    .as_mapping_mut()
214
    .ok_or_else(|| anyhow::anyhow!("Root config must be a YAML mapping to support --write-config"))?;
215

            
216
  remove_legacy_secret_fields(mapping);
217
  mapping.insert(
218
    Value::String(String::from("secrets")),
219
    secret_settings_to_yaml_value(secrets),
220
  );
221
  mapping
222
    .entry(Value::String(String::from("tasks")))
223
    .or_insert_with(|| Value::Mapping(Mapping::new()));
224

            
225
  let mut rendered = String::new();
226
  if !prefix.is_empty() {
227
    rendered.push_str(&prefix);
228
    if !prefix.ends_with('\n') {
229
      rendered.push('\n');
230
    }
231
  }
232
  rendered.push_str(&serde_yaml::to_string(&value)?);
233
  fs::write(config_path, rendered)?;
234
  Ok(())
235
}
236

            
237
fn leading_yaml_preamble(contents: &str) -> String {
238
  let mut prefix = String::new();
239
  for line in contents.lines() {
240
    let trimmed = line.trim();
241
    if trimmed.is_empty() || trimmed.starts_with('#') {
242
      prefix.push_str(line);
243
      prefix.push('\n');
244
      continue;
245
    }
246
    break;
247
  }
248
  prefix
249
}
250

            
251
fn remove_legacy_secret_fields(mapping: &mut Mapping) {
252
  for key in [
253
    "vault_location",
254
    "keys_location",
255
    "key_name",
256
    "gpg_key_id",
257
    "secrets_path",
258
  ] {
259
    mapping.remove(Value::String(key.to_string()));
260
  }
261
}
262

            
263
fn secret_settings_to_yaml_value(settings: &SecretSettings) -> Value {
264
  let mut mapping = Mapping::new();
265

            
266
  if let Some(backend) = &settings.backend {
267
    let backend = match backend {
268
      SecretBackend::BuiltInPgp => "built_in_pgp",
269
      SecretBackend::Gpg => "gpg",
270
    };
271
    mapping.insert(
272
      Value::String(String::from("backend")),
273
      Value::String(backend.to_string()),
274
    );
275
  }
276
  if let Some(vault_location) = &settings.vault_location {
277
    mapping.insert(
278
      Value::String(String::from("vault_location")),
279
      Value::String(vault_location.clone()),
280
    );
281
  }
282
  if let Some(keys_location) = &settings.keys_location {
283
    mapping.insert(
284
      Value::String(String::from("keys_location")),
285
      Value::String(keys_location.clone()),
286
    );
287
  }
288
  if let Some(key_name) = &settings.key_name {
289
    mapping.insert(
290
      Value::String(String::from("key_name")),
291
      Value::String(key_name.clone()),
292
    );
293
  }
294
  if let Some(gpg_key_id) = &settings.gpg_key_id {
295
    mapping.insert(
296
      Value::String(String::from("gpg_key_id")),
297
      Value::String(gpg_key_id.clone()),
298
    );
299
  }
300
  if let Some(secrets_path) = &settings.secrets_path {
301
    mapping.insert(
302
      Value::String(String::from("secrets_path")),
303
      Value::Sequence(
304
        secrets_path
305
          .iter()
306
          .map(|path| Value::String(path.clone()))
307
          .collect::<Vec<_>>(),
308
      ),
309
    );
310
  }
311

            
312
  Value::Mapping(mapping)
313
}