1use crate::colors;
2use crate::config::{CURRENT_RUNTIME_SCHEMA_VERSION, Config};
3use anyhow::{Context, Result, bail};
4use std::collections::BTreeSet;
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8
9const UPDATE_CACHE_FILE: &str = "update-check.json";
10
11pub async fn handle_migrate(config: &Config, dry_run: bool) -> Result<()> {
12 let schema_version = config.schema_version;
13 if schema_version > CURRENT_RUNTIME_SCHEMA_VERSION {
14 bail!(
15 "runtime schema {} is newer than this shine supports ({})",
16 schema_version,
17 CURRENT_RUNTIME_SCHEMA_VERSION
18 );
19 }
20
21 validate_recipient_migration_inputs(config).await?;
25
26 let steps = pending_steps(schema_version);
27 if steps.is_empty() {
28 let mut migrated_local_state = false;
29 for path in config_migration_paths(config) {
30 if config_needs_migration(&path).await? {
31 let age_count = config_age_migration_count(&path).await?;
32 if dry_run {
33 println!(
34 "[dry-run] migrate recipient configuration in {} ({age_count} age1se recipient(s) to age1tag)",
35 path.display(),
36 );
37 } else {
38 migrate_config_gpg_recipient(&path).await?;
39 println!(
40 "Migrated recipient configuration in {} ({age_count} age1se recipient(s) to age1tag)",
41 path.display()
42 );
43 }
44 if age_count > 0 {
45 println!(
46 " Note: native tagged recipients let someone who knows the recipient test whether a ciphertext targets it."
47 );
48 }
49 migrated_local_state = true;
50 }
51 }
52 if let Some(path) = find_workspace_from_current_dir()
53 && workspace_needs_migration(&path).await?
54 {
55 let age_count = workspace_age_migration_count(&path).await?;
56 if dry_run {
57 println!(
58 "[dry-run] migrate workspace format and recipients in {} ({age_count} age1se recipient(s) to age1tag)",
59 path.display()
60 );
61 } else {
62 migrate_workspace_gpg_recipient(&path).await?;
63 println!(
64 "Migrated workspace format and recipients in {} ({age_count} age1se recipient(s) to age1tag)",
65 path.display()
66 );
67 }
68 if age_count > 0 {
69 println!(
70 " Note: native tagged recipients let someone who knows the recipient test whether a ciphertext targets it."
71 );
72 }
73 migrated_local_state = true;
74 }
75 if migrated_local_state {
76 return Ok(());
77 }
78 if !dry_run && config.last_cleared_schema_version != Some(CURRENT_RUNTIME_SCHEMA_VERSION) {
79 let mut updated = config.clone();
80 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
81 updated.save().await?;
82 }
83 println!(
84 "{}",
85 colors::green(&format!(
86 "Runtime config schema is already current ({CURRENT_RUNTIME_SCHEMA_VERSION})."
87 ))
88 );
89 return Ok(());
90 }
91
92 println!("{}", colors::bold("Migrating old runtime state"));
93 crate::config::print_presets_note(config);
94
95 for step in &steps {
96 println!("{}", colors::dim(&format!("schema {}", step.to_version)));
97 for action in actions_for_step(config, step).await? {
98 if dry_run {
99 println!(" [dry-run] {}", action.description);
100 } else {
101 println!(" {}", action.description);
102 (action.apply).await?;
103 }
104 }
105 }
106
107 if dry_run {
108 println!();
109 println!(
110 "{}",
111 colors::dim("Dry run only. Run `shine state migrate` to apply these changes.")
112 );
113 return Ok(());
114 }
115
116 let mut updated = config.clone();
117 if let Some(recipients) = gpg_recipients_from_config(config.config_path()).await? {
118 updated.gpg_recipients = recipients;
119 }
120 if let Some(recipients) = age_recipients_from_config(config.config_path()).await? {
121 updated.age_recipients = recipients;
122 }
123 updated.legacy_gpg_key_id = None;
124 updated.schema_version = CURRENT_RUNTIME_SCHEMA_VERSION;
125 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
126 updated.save().await?;
127
128 println!();
129 println!(
130 "{}",
131 colors::green(&format!(
132 "Runtime config schema is now {CURRENT_RUNTIME_SCHEMA_VERSION}."
133 ))
134 );
135 Ok(())
136}
137
138pub fn pending_schema_warning(schema_version: u32) -> Option<String> {
139 if schema_version >= CURRENT_RUNTIME_SCHEMA_VERSION {
140 return None;
141 }
142
143 Some(format!(
144 "Runtime config schema is behind: {schema_version} -> {CURRENT_RUNTIME_SCHEMA_VERSION}. Run `shine state migrate --dry-run` to inspect cleanup, then `shine state migrate`."
145 ))
146}
147
148struct CleanupStep {
149 to_version: u32,
150}
151
152struct CleanupAction<'a> {
153 description: String,
154 apply: Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>,
155}
156
157fn pending_steps(schema_version: u32) -> Vec<CleanupStep> {
158 ((schema_version + 1)..=CURRENT_RUNTIME_SCHEMA_VERSION)
159 .map(|to_version| CleanupStep { to_version })
160 .collect()
161}
162
163async fn actions_for_step<'a>(
164 config: &'a Config,
165 step: &CleanupStep,
166) -> Result<Vec<CleanupAction<'a>>> {
167 Ok(match step.to_version {
168 1 => {
169 let path = config.shine_dir().join(UPDATE_CACHE_FILE);
170 vec![CleanupAction {
171 description: format!("remove stale update cache {}", path.display()),
172 apply: Box::pin(async move {
173 match tokio::fs::remove_file(&path).await {
174 Ok(()) => Ok(()),
175 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
176 Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
177 }
178 }),
179 }]
180 }
181 2 => migration_actions(config).await?,
182 _ => Vec::new(),
183 })
184}
185
186async fn migration_actions<'a>(config: &'a Config) -> Result<Vec<CleanupAction<'a>>> {
187 let mut actions = Vec::new();
188 for path in config_migration_paths(config) {
189 let age_count = config_age_migration_count(&path).await?;
190 let description = migration_description("recipient configuration", &path, age_count);
191 actions.push(CleanupAction {
192 description,
193 apply: Box::pin(async move { migrate_config_gpg_recipient(&path).await }),
194 });
195 }
196 if let Some(path) = find_workspace_from_current_dir() {
197 let age_count = workspace_age_migration_count(&path).await?;
198 let description =
199 migration_description("workspace recipient configuration", &path, age_count);
200 actions.push(CleanupAction {
201 description,
202 apply: Box::pin(
203 async move { migrate_workspace_gpg_recipient(&path).await.map(|_| ()) },
204 ),
205 });
206 }
207 Ok(actions)
208}
209
210fn migration_description(kind: &str, path: &Path, age_count: usize) -> String {
211 let mut description = format!(
212 "migrate {kind} in {} ({age_count} age1se recipient(s) to age1tag)",
213 path.display()
214 );
215 if age_count > 0 {
216 description.push_str(
217 "; tagged recipients let someone who knows the recipient test whether a ciphertext targets it",
218 );
219 }
220 description
221}
222
223fn config_migration_paths(config: &Config) -> Vec<PathBuf> {
224 let mut paths = BTreeSet::from([config.config_path().to_path_buf()]);
225 if let Ok(current_dir) = std::env::current_dir()
226 && let Some(project) = crate::config::find_project_config(¤t_dir)
227 {
228 paths.insert(project.path);
229 }
230 paths.into_iter().filter(|path| path.is_file()).collect()
231}
232
233fn find_workspace_from_current_dir() -> Option<PathBuf> {
234 let current_dir = std::env::current_dir().ok()?;
235 current_dir
236 .ancestors()
237 .map(|dir| dir.join("shine.workspace.toml"))
238 .find(|path| path.is_file())
239}
240
241async fn validate_recipient_migration_inputs(config: &Config) -> Result<()> {
242 for path in config_migration_paths(config) {
243 config_age_migration_count(&path)
244 .await
245 .with_context(|| format!("validating recipient migration in {}", path.display()))?;
246 }
247 if let Some(path) = find_workspace_from_current_dir() {
248 workspace_age_migration_count(&path)
249 .await
250 .with_context(|| format!("validating recipient migration in {}", path.display()))?;
251 }
252 Ok(())
253}
254
255async fn migrate_config_gpg_recipient(path: &Path) -> Result<()> {
256 migrate_recipient_key(path, |document| {
257 let mut changed = migrate_key(document, "gpg_key_id", "gpg_recipients")?;
258 changed |= migrate_age_recipients(document.as_table_mut())? > 0;
259 Ok(changed)
260 })
261 .await
262 .map(|_| ())
263}
264
265async fn migrate_workspace_gpg_recipient(path: &Path) -> Result<bool> {
266 migrate_recipient_key(path, |document| {
267 let version = document
268 .as_table()
269 .get("version")
270 .and_then(toml_edit::Item::as_integer)
271 .unwrap_or(1);
272 if version > 2 {
273 bail!("workspace version {version} is newer than this shine supports (2)");
274 }
275 let mut changed = false;
276 let encryption = document["env"]["encryption"].as_table_mut();
277 if let Some(table) = encryption {
278 changed |= migrate_table_key(table, "recipient", "gpg_recipients")?;
279 changed |= migrate_age_recipients(table)? > 0;
280 }
281 if version < 2 {
282 document["version"] = toml_edit::value(2);
283 changed = true;
284 }
285 Ok(changed)
286 })
287 .await
288}
289
290async fn migrate_recipient_key(
291 path: &Path,
292 mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>,
293) -> Result<bool> {
294 let contents = tokio::fs::read_to_string(path)
295 .await
296 .with_context(|| format!("reading {}", path.display()))?;
297 let mut document = contents
298 .parse::<toml_edit::DocumentMut>()
299 .with_context(|| format!("parsing {}", path.display()))?;
300 if mutate(&mut document)? {
301 crate::persist::atomic_write(path, document.to_string().as_bytes())
302 .await
303 .with_context(|| format!("writing {}", path.display()))?;
304 return Ok(true);
305 }
306 Ok(false)
307}
308
309async fn workspace_needs_migration(path: &Path) -> Result<bool> {
310 let contents = tokio::fs::read_to_string(path)
311 .await
312 .with_context(|| format!("reading {}", path.display()))?;
313 let document = contents
314 .parse::<toml_edit::DocumentMut>()
315 .with_context(|| format!("parsing {}", path.display()))?;
316 Ok(document
317 .as_table()
318 .get("version")
319 .and_then(toml_edit::Item::as_integer)
320 .unwrap_or(1)
321 < 2
322 || document["env"]["encryption"]["recipient"].is_value()
323 || age_migration_count(
324 document["env"]["encryption"]
325 .as_table()
326 .and_then(|table| table.get("age_recipients")),
327 )? > 0)
328}
329
330async fn config_needs_migration(path: &Path) -> Result<bool> {
331 let contents = tokio::fs::read_to_string(path)
332 .await
333 .with_context(|| format!("reading {}", path.display()))?;
334 let document = contents
335 .parse::<toml_edit::DocumentMut>()
336 .with_context(|| format!("parsing {}", path.display()))?;
337 Ok(document.as_table().contains_key("gpg_key_id")
338 || age_migration_count(document.as_table().get("age_recipients"))? > 0)
339}
340
341async fn config_age_migration_count(path: &Path) -> Result<usize> {
342 let contents = tokio::fs::read_to_string(path)
343 .await
344 .with_context(|| format!("reading {}", path.display()))?;
345 let document = contents
346 .parse::<toml_edit::DocumentMut>()
347 .with_context(|| format!("parsing {}", path.display()))?;
348 age_migration_count(document.as_table().get("age_recipients"))
349}
350
351async fn workspace_age_migration_count(path: &Path) -> Result<usize> {
352 let contents = tokio::fs::read_to_string(path)
353 .await
354 .with_context(|| format!("reading {}", path.display()))?;
355 let document = contents
356 .parse::<toml_edit::DocumentMut>()
357 .with_context(|| format!("parsing {}", path.display()))?;
358 age_migration_count(
359 document["env"]["encryption"]
360 .as_table()
361 .and_then(|table| table.get("age_recipients")),
362 )
363}
364
365fn age_migration_count(item: Option<&toml_edit::Item>) -> Result<usize> {
366 let Some(item) = item else {
367 return Ok(0);
368 };
369 let recipients = item.as_array().context("age_recipients must be an array")?;
370 let mut count = 0;
371 for value in recipients {
372 let recipient = value
373 .as_str()
374 .context("age_recipients entries must be strings")?;
375 if recipient.starts_with("age1se1") {
376 crate::secret::secure_enclave_recipient_to_tag(recipient)?;
377 count += 1;
378 }
379 }
380 Ok(count)
381}
382
383fn migrate_age_recipients(table: &mut toml_edit::Table) -> Result<usize> {
384 let Some(item) = table.get_mut("age_recipients") else {
385 return Ok(0);
386 };
387 let recipients = item
388 .as_array_mut()
389 .context("age_recipients must be an array")?;
390 let mut count = 0;
391 for value in recipients.iter_mut() {
392 let recipient = value
393 .as_str()
394 .context("age_recipients entries must be strings")?;
395 if recipient.starts_with("age1se1") {
396 let tagged = crate::secret::secure_enclave_recipient_to_tag(recipient)?;
397 let decor = value.decor().clone();
398 *value = toml_edit::Value::from(tagged);
399 *value.decor_mut() = decor;
400 count += 1;
401 }
402 }
403 Ok(count)
404}
405
406fn migrate_key(document: &mut toml_edit::DocumentMut, old: &str, new: &str) -> Result<bool> {
407 migrate_table_key(document.as_table_mut(), old, new)
408}
409
410fn migrate_table_key(table: &mut toml_edit::Table, old: &str, new: &str) -> Result<bool> {
411 let Some(old_item) = table.get(old) else {
412 return Ok(false);
413 };
414 if table.contains_key(new) {
415 bail!("configuration contains both {old} and {new}; resolve the conflict before migrating");
416 }
417 let recipient = old_item
418 .as_str()
419 .context("legacy GPG recipient must be a string")?
420 .to_owned();
421 let key_decor = table.key(old).map(|key| key.leaf_decor().clone());
422 let decor = old_item.as_value().map(|value| value.decor().clone());
423 table.remove(old);
424 let mut recipients = toml_edit::Array::new();
425 recipients.push(recipient);
426 table.insert(
427 new,
428 toml_edit::Item::Value(toml_edit::Value::Array(recipients)),
429 );
430 if let (Some(decor), Some(value)) = (
431 decor,
432 table.get_mut(new).and_then(toml_edit::Item::as_value_mut),
433 ) {
434 *value.decor_mut() = decor;
435 }
436 if let (Some(decor), Some(mut key)) = (key_decor, table.key_mut(new)) {
437 *key.leaf_decor_mut() = decor;
438 }
439 Ok(true)
440}
441
442async fn gpg_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
443 recipients_from_config(path, "gpg_recipients").await
444}
445
446async fn age_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
447 recipients_from_config(path, "age_recipients").await
448}
449
450async fn recipients_from_config(path: &Path, key: &str) -> Result<Option<Vec<String>>> {
451 let contents = match tokio::fs::read_to_string(path).await {
452 Ok(contents) => contents,
453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
454 Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
455 };
456 let table: toml::Table =
457 toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
458 let Some(value) = table.get(key) else {
459 return Ok(None);
460 };
461 let recipients = value
462 .as_array()
463 .with_context(|| format!("{key} must be an array"))?
464 .iter()
465 .map(|value| {
466 value
467 .as_str()
468 .with_context(|| format!("{key} entries must be strings"))
469 })
470 .collect::<Result<Vec<_>>>()?
471 .into_iter()
472 .map(str::to_owned)
473 .collect();
474 Ok(Some(recipients))
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use tokio::fs;
481
482 async fn make_temp_dir() -> std::path::PathBuf {
483 crate::test_support::make_temp_dir("shine-state-migrate").await
484 }
485
486 #[test]
487 fn pending_schema_warning_reports_old_schema() {
488 let warning = pending_schema_warning(0).unwrap();
489 assert!(warning.contains("0 -> 2"));
490 assert!(pending_schema_warning(CURRENT_RUNTIME_SCHEMA_VERSION).is_none());
491 }
492
493 #[tokio::test]
494 async fn migrate_removes_update_cache_and_records_schema() {
495 let dir = make_temp_dir().await;
496 let mut config = Config::new_for_test(&dir);
497 config.schema_version = 0;
498 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
499 .await
500 .unwrap();
501
502 handle_migrate(&config, false).await.unwrap();
503
504 assert!(!dir.join(UPDATE_CACHE_FILE).exists());
505 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
506 let parsed: toml::Table = toml::from_str(&content).unwrap();
507 assert_eq!(
508 parsed["schema_version"].as_integer(),
509 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
510 );
511 assert_eq!(
512 parsed["last_cleared_schema_version"].as_integer(),
513 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
514 );
515
516 fs::remove_dir_all(&dir).await.unwrap();
517 }
518
519 #[tokio::test]
520 async fn migrate_converts_legacy_gpg_recipient_to_a_list() {
521 let dir = make_temp_dir().await;
522 let mut config = Config::new_for_test(&dir);
523 config.schema_version = 1;
524 fs::write(
525 config.config_path(),
526 "# team encryption key\ngpg_key_id = \"alice@example.com\"\n",
527 )
528 .await
529 .unwrap();
530
531 handle_migrate(&config, false).await.unwrap();
532
533 let content = fs::read_to_string(config.config_path()).await.unwrap();
534 assert!(!content.contains("gpg_key_id"));
535 assert!(content.contains("# team encryption key"));
536 let parsed: toml::Table = toml::from_str(&content).unwrap();
537 assert_eq!(
538 parsed["gpg_recipients"].as_array().unwrap(),
539 &[toml::Value::String("alice@example.com".to_string())]
540 );
541 assert_eq!(
542 parsed["schema_version"].as_integer(),
543 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
544 );
545
546 fs::remove_dir_all(&dir).await.unwrap();
547 }
548
549 #[tokio::test]
550 async fn workspace_migration_converts_legacy_recipient() {
551 let dir = make_temp_dir().await;
552 let workspace = dir.join("shine.workspace.toml");
553 let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
554 let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
555 fs::write(
556 &workspace,
557 format!(
558 "version = 2\n[env.encryption]\n# deployment key\nrecipient = \"alice@example.com\"\nage_recipients = [\"{legacy}\"]\n"
559 ),
560 )
561 .await
562 .unwrap();
563
564 migrate_workspace_gpg_recipient(&workspace).await.unwrap();
565
566 let content = fs::read_to_string(&workspace).await.unwrap();
567 assert!(!content.contains("recipient ="));
568 assert!(content.contains("# deployment key"));
569 assert!(content.contains("gpg_recipients = [\"alice@example.com\"]"));
570 assert!(content.contains(tagged));
571 assert!(content.contains("version = 2"));
572 fs::remove_dir_all(&dir).await.unwrap();
573 }
574
575 #[tokio::test]
576 async fn migration_converts_secure_enclave_recipients_in_place() {
577 let dir = make_temp_dir().await;
578 let config_path = dir.join("config.toml");
579 let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
580 let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
581 fs::write(
582 &config_path,
583 format!("# team recipients\nage_recipients = [\n \"{legacy}\", # Touch ID\n \"age1phone1example\",\n]\n"),
584 )
585 .await
586 .unwrap();
587
588 migrate_config_gpg_recipient(&config_path).await.unwrap();
589
590 let content = fs::read_to_string(&config_path).await.unwrap();
591 assert!(content.contains(tagged));
592 assert!(content.contains("# Touch ID"));
593 assert!(content.contains("age1phone1example"));
594 assert!(!content.contains(legacy));
595 fs::remove_dir_all(&dir).await.unwrap();
596 }
597
598 #[tokio::test]
599 async fn invalid_secure_enclave_recipient_leaves_file_unchanged() {
600 let dir = make_temp_dir().await;
601 let config_path = dir.join("config.toml");
602 let original = "age_recipients = [\"age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp\", \"age1se1invalid\"]\n";
603 fs::write(&config_path, original).await.unwrap();
604
605 assert!(migrate_config_gpg_recipient(&config_path).await.is_err());
606 assert_eq!(fs::read_to_string(&config_path).await.unwrap(), original);
607 fs::remove_dir_all(&dir).await.unwrap();
608 }
609
610 #[tokio::test]
611 #[allow(clippy::await_holding_lock)]
612 async fn invalid_project_recipient_prevents_any_config_write() {
613 let _lock = crate::test_support::env_lock();
614 let original_dir = std::env::current_dir().unwrap();
615 let dir = make_temp_dir().await;
616 let project_dir = dir.join("project");
617 fs::create_dir_all(&project_dir).await.unwrap();
618 let config = Config::new_for_test(&dir);
619 let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
620 let global_original = format!("schema_version = 2\nage_recipients = [\"{legacy}\"]\n");
621 fs::write(config.config_path(), &global_original)
622 .await
623 .unwrap();
624 fs::write(
625 project_dir.join("shine.config.toml"),
626 "age_recipients = [\"age1se1invalid\"]\n",
627 )
628 .await
629 .unwrap();
630 std::env::set_current_dir(&project_dir).unwrap();
631
632 let result = handle_migrate(&config, false).await;
633 crate::test_support::restore_current_dir(&original_dir);
634
635 assert!(result.is_err());
636 assert_eq!(
637 fs::read_to_string(config.config_path()).await.unwrap(),
638 global_original
639 );
640 fs::remove_dir_all(&dir).await.unwrap();
641 }
642
643 #[tokio::test]
644 async fn dry_run_does_not_convert_secure_enclave_recipients() {
645 let dir = make_temp_dir().await;
646 let config = Config::new_for_test(&dir);
647 let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
648 let original = format!("schema_version = 2\nage_recipients = [\"{legacy}\"]\n");
649 fs::write(config.config_path(), &original).await.unwrap();
650
651 handle_migrate(&config, true).await.unwrap();
652
653 assert_eq!(
654 fs::read_to_string(config.config_path()).await.unwrap(),
655 original
656 );
657 fs::remove_dir_all(&dir).await.unwrap();
658 }
659
660 #[tokio::test]
661 async fn dry_run_does_not_remove_or_save() {
662 let dir = make_temp_dir().await;
663 let mut config = Config::new_for_test(&dir);
664 config.schema_version = 0;
665 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
666 .await
667 .unwrap();
668
669 handle_migrate(&config, true).await.unwrap();
670
671 assert!(dir.join(UPDATE_CACHE_FILE).exists());
672 assert!(!dir.join("config.toml").exists());
673
674 fs::remove_dir_all(&dir).await.unwrap();
675 }
676
677 #[tokio::test]
678 async fn migrate_records_last_cleared_when_already_current() {
679 let dir = make_temp_dir().await;
680 let config = Config::new_for_test(&dir);
681
682 handle_migrate(&config, false).await.unwrap();
683
684 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
685 let parsed: toml::Table = toml::from_str(&content).unwrap();
686 assert_eq!(
687 parsed["schema_version"].as_integer(),
688 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
689 );
690 assert_eq!(
691 parsed["last_cleared_schema_version"].as_integer(),
692 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
693 );
694
695 fs::remove_dir_all(&dir).await.unwrap();
696 }
697
698 #[tokio::test]
699 #[allow(clippy::await_holding_lock)]
700 async fn migrate_current_schema_converts_legacy_project_config() {
701 let _lock = crate::test_support::env_lock();
702 let original_dir = std::env::current_dir().unwrap();
703 let dir = make_temp_dir().await;
704 let project_dir = dir.join("project");
705 fs::create_dir_all(&project_dir).await.unwrap();
706 let project_config = project_dir.join("shine.config.toml");
707 let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
708 let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
709 fs::write(
710 &project_config,
711 format!(
712 "# project key\ngpg_key_id = \"project@example.com\"\nage_recipients = [\"{legacy}\"]\n"
713 ),
714 )
715 .await
716 .unwrap();
717 std::env::set_current_dir(&project_dir).unwrap();
718 let config = Config::new_for_test(&dir);
719
720 let result = handle_migrate(&config, false).await;
721 crate::test_support::restore_current_dir(&original_dir);
722 result.unwrap();
723
724 let content = fs::read_to_string(&project_config).await.unwrap();
725 assert!(!content.contains("gpg_key_id"));
726 assert!(content.contains("# project key"));
727 assert!(content.contains("gpg_recipients = [\"project@example.com\"]"));
728 assert!(content.contains(tagged));
729
730 fs::remove_dir_all(&dir).await.unwrap();
731 }
732
733 #[tokio::test]
734 async fn migrate_does_not_remove_other_runtime_files() {
735 let dir = make_temp_dir().await;
736 let mut config = Config::new_for_test(&dir);
737 config.schema_version = 0;
738 fs::create_dir_all(dir.join("rendered")).await.unwrap();
739 fs::create_dir_all(dir.join("bin")).await.unwrap();
740 fs::create_dir_all(dir.join("presets")).await.unwrap();
741 fs::write(dir.join("app-manifest.toml"), b"entries = []")
742 .await
743 .unwrap();
744
745 handle_migrate(&config, false).await.unwrap();
746
747 assert!(dir.join("rendered").exists());
748 assert!(dir.join("bin").exists());
749 assert!(dir.join("presets").exists());
750 assert!(dir.join("app-manifest.toml").exists());
751
752 fs::remove_dir_all(&dir).await.unwrap();
753 }
754}