1use std::cell::RefCell;
18use std::fs;
19use std::io::ErrorKind::NotFound;
20use std::io::Write as _;
21use std::path::Path;
22use std::path::PathBuf;
23
24use prost::Message as _;
25use rand::RngExt as _;
26use rand_chacha::ChaCha20Rng;
27use tempfile::NamedTempFile;
28use thiserror::Error;
29
30use crate::file_util::BadPathEncoding;
31use crate::file_util::IoResultExt as _;
32use crate::file_util::PathError;
33use crate::file_util::path_from_bytes;
34use crate::file_util::path_to_bytes;
35use crate::hex_util::encode_hex;
36use crate::protos::secure_config::ConfigMetadata;
37
38const CONFIG_FILE: &str = "config.toml";
39const METADATA_FILE: &str = "metadata.binpb";
40const CONFIG_ID_BYTES: usize = 10;
41#[cfg(not(unix))]
42const CONTENT_PREFIX: &str = r###"# DO NOT EDIT.
43# This file is for old versions of jj.
44# It will be used for jj >= v0.37.
45# Use `jj config path` or `jj config edit` to find and edit the new file
46
47"###;
48const CONFIG_NOT_FOUND: &str = r###"Per-repo config not found. Generating an empty one.
49Per-repo config is stored in the same directory as your user config for security reasons.
50If you work across multiple computers, you may want to keep your user config directory in sync."###;
51
52#[derive(Clone, Debug)]
54pub struct SecureConfig {
55 repo_dir: PathBuf,
57 config_id_name: &'static str,
59 legacy_config_name: &'static str,
61 cache: RefCell<Option<(Option<PathBuf>, ConfigMetadata)>>,
63}
64
65#[derive(Error, Debug)]
67pub enum SecureConfigError {
68 #[error(transparent)]
70 PathError(#[from] PathError),
71
72 #[error(transparent)]
74 DecodeError(#[from] prost::DecodeError),
75
76 #[error(transparent)]
78 BadPathEncoding(#[from] BadPathEncoding),
79
80 #[error("Found an invalid config ID")]
82 BadConfigIdError,
83}
84
85#[derive(Clone, Debug, Default)]
89pub struct LoadedSecureConfig {
90 pub config_file: Option<PathBuf>,
93 pub metadata: ConfigMetadata,
95 pub warnings: Vec<String>,
97}
98
99fn atomic_write(path: &Path, content: &[u8]) -> Result<(), SecureConfigError> {
100 let d = path.parent().unwrap();
101 let mut temp_file = NamedTempFile::new_in(d).context(d)?;
102 temp_file.write_all(content).context(temp_file.path())?;
103 temp_file.persist(path).map_err(|e| PathError {
104 path: path.to_path_buf(),
105 source: e.error,
106 })?;
107 Ok(())
108}
109
110fn generate_config_id(rng: &mut ChaCha20Rng) -> String {
111 encode_hex(&rng.random::<[u8; CONFIG_ID_BYTES]>())
112}
113
114fn update_metadata(config_dir: &Path, metadata: &ConfigMetadata) -> Result<(), SecureConfigError> {
115 let metadata_path = config_dir.join(METADATA_FILE);
116 atomic_write(&metadata_path, &metadata.encode_to_vec())?;
117 Ok(())
118}
119
120pub fn read_metadata(config_dir: &Path) -> Result<ConfigMetadata, SecureConfigError> {
123 let metadata_path = config_dir.join(METADATA_FILE);
124 let bytes = fs::read(&metadata_path).context(&metadata_path)?;
125 Ok(ConfigMetadata::decode(bytes.as_slice())?)
126}
127
128pub fn metadata_path(metadata: &ConfigMetadata) -> Result<Option<&Path>, BadPathEncoding> {
130 metadata.path.as_deref().map(path_from_bytes).transpose()
131}
132
133pub fn remove_repo_config_dir(config_dir: &Path) -> std::io::Result<()> {
140 for path in [config_dir.join(CONFIG_FILE), config_dir.join(METADATA_FILE)] {
141 match fs::remove_file(&path) {
142 Ok(()) => {}
143 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
144 Err(err) => return Err(err),
145 }
146 }
147 fs::remove_dir(config_dir)
148}
149
150impl SecureConfig {
151 fn new(
153 repo_dir: PathBuf,
154 config_id_name: &'static str,
155 legacy_config_name: &'static str,
156 ) -> Self {
157 Self {
158 repo_dir,
159 config_id_name,
160 legacy_config_name,
161 cache: RefCell::new(None),
162 }
163 }
164
165 pub fn new_repo(repo_dir: PathBuf) -> Self {
167 Self::new(repo_dir, "config-id", "config.toml")
168 }
169
170 pub fn new_workspace(workspace_dir: PathBuf) -> Self {
172 Self::new(
173 workspace_dir,
174 "workspace-config-id",
175 "workspace-config.toml",
176 )
177 }
178
179 fn generate_config(
180 &self,
181 root_config_dir: &Path,
182 config_id: &str,
183 content: Option<&[u8]>,
184 metadata: &ConfigMetadata,
185 ) -> Result<PathBuf, SecureConfigError> {
186 let config_dir = root_config_dir.join(config_id);
187 let config_path = config_dir.join(CONFIG_FILE);
188 fs::create_dir_all(&config_dir).context(&config_dir)?;
189 update_metadata(&config_dir, metadata)?;
190 if let Some(content) = content {
191 fs::write(&config_path, content).context(&config_path)?;
192 }
193
194 atomic_write(
196 &self.repo_dir.join(self.config_id_name),
197 config_id.as_bytes(),
198 )?;
199 Ok(config_path)
200 }
201
202 fn generate_initial_config(
203 &self,
204 root_config_dir: &Path,
205 config_id: &str,
206 ) -> Result<(PathBuf, ConfigMetadata), SecureConfigError> {
207 let metadata = ConfigMetadata {
208 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
209 };
210 let path = self.generate_config(root_config_dir, config_id, None, &metadata)?;
211 Ok((path, metadata))
212 }
213
214 fn handle_metadata_path(
218 &self,
219 rng: &mut ChaCha20Rng,
220 root_config_dir: &Path,
221 config_dir: PathBuf,
222 mut metadata: ConfigMetadata,
223 ) -> Result<LoadedSecureConfig, SecureConfigError> {
224 let encoded = path_to_bytes(&self.repo_dir).ok();
225 let got = metadata_path(&metadata)?;
226
227 if got == encoded.is_some().then_some(self.repo_dir.as_path()) {
228 return Ok(LoadedSecureConfig {
229 config_file: Some(config_dir.join(CONFIG_FILE)),
230 metadata,
231 warnings: vec![],
232 });
233 }
234 let got = match got {
235 Some(d) if d.is_dir() => d.to_path_buf(),
236 _ => {
237 metadata.path = encoded.map(|b| b.to_vec());
239 update_metadata(&config_dir, &metadata)?;
240 return Ok(LoadedSecureConfig {
241 config_file: Some(config_dir.join(CONFIG_FILE)),
242 metadata,
243 warnings: vec![],
244 });
245 }
246 };
247 if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
252 && !got.join(tmp.path().file_name().unwrap()).exists()
253 {
254 let old_config_path = config_dir.join(CONFIG_FILE);
258 metadata.path = encoded.map(|b| b.to_vec());
259 let old_config_content = match fs::read(&old_config_path).context(&old_config_path) {
260 Ok(content) => Some(content),
261 Err(err) if err.source.kind() == NotFound => None,
262 Err(err) => return Err(err.into()),
263 };
264 let config_path = self.generate_config(
265 root_config_dir,
266 &generate_config_id(rng),
267 old_config_content.as_deref(),
268 &metadata,
269 )?;
270 return Ok(LoadedSecureConfig {
271 config_file: Some(config_path.clone()),
272 metadata,
273 warnings: vec![format!(
274 "Your repo appears to have been copied from {} to {}. The corresponding repo \
275 config file has also been copied.",
276 got.display(),
277 &self.repo_dir.display()
278 )],
279 });
280 }
281 Ok(LoadedSecureConfig {
282 config_file: Some(config_dir.join(CONFIG_FILE)),
283 metadata,
284 warnings: vec![],
285 })
286 }
287
288 #[cfg(unix)]
289 fn update_legacy_config_file(
290 &self,
291 new_config: &Path,
292 _content: &[u8],
293 ) -> Result<(), SecureConfigError> {
294 let legacy_config = self.repo_dir.join(self.legacy_config_name);
295 fs::remove_file(&legacy_config).context(&legacy_config)?;
297 std::os::unix::fs::symlink(new_config, &legacy_config).context(&legacy_config)?;
298 Ok(())
299 }
300
301 #[cfg(not(unix))]
302 fn update_legacy_config_file(
303 &self,
304 _new_config: &Path,
305 content: &[u8],
306 ) -> Result<(), SecureConfigError> {
307 let legacy_config = self.repo_dir.join(self.legacy_config_name);
308 let mut new_content = CONTENT_PREFIX.as_bytes().to_vec();
315 new_content.extend_from_slice(content);
316 fs::write(&legacy_config, new_content).context(&legacy_config)?;
317 Ok(())
318 }
319
320 fn maybe_migrate_legacy_config(
322 &self,
323 rng: &mut ChaCha20Rng,
324 root_config_dir: &Path,
325 ) -> Result<LoadedSecureConfig, SecureConfigError> {
326 let legacy_config = self.repo_dir.join(self.legacy_config_name);
329 let config = match fs::read(&legacy_config).context(&legacy_config) {
330 Ok(config_content) => config_content,
331 Err(e) if e.source.kind() == NotFound => return Ok(Default::default()),
333 Err(e) => return Err(e.into()),
334 };
335 let metadata = ConfigMetadata {
336 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
337 };
338 let config_file = self.generate_config(
339 root_config_dir,
340 &generate_config_id(rng),
341 Some(&config),
342 &metadata,
343 )?;
344 self.update_legacy_config_file(&config_file, &config)?;
345 Ok(LoadedSecureConfig {
346 warnings: vec![format!(
347 "Your config file has been migrated from {} to {}. You can edit the new file with \
348 `jj config edit`",
349 legacy_config.display(),
350 config_file.display(),
351 )],
352 config_file: Some(config_file),
353 metadata,
354 })
355 }
356
357 pub fn maybe_load_config(
360 &self,
361 rng: &mut ChaCha20Rng,
362 root_config_dir: &Path,
363 ) -> Result<LoadedSecureConfig, SecureConfigError> {
364 if let Some(cache) = self.cache.borrow().as_ref() {
365 return Ok(LoadedSecureConfig {
366 config_file: cache.0.clone(),
367 metadata: cache.1.clone(),
368 warnings: vec![],
369 });
370 }
371 let config_id_path = self.repo_dir.join(self.config_id_name);
372 let loaded = match fs::read_to_string(&config_id_path).context(&config_id_path) {
373 Ok(config_id) => {
374 if config_id.len() != CONFIG_ID_BYTES * 2
375 || !config_id.chars().all(|c| c.is_ascii_hexdigit())
376 {
377 return Err(SecureConfigError::BadConfigIdError);
378 }
379 let config_dir = root_config_dir.join(&config_id);
380 match read_metadata(&config_dir) {
381 Ok(metadata) => {
382 self.handle_metadata_path(rng, root_config_dir, config_dir, metadata)?
383 }
384 Err(SecureConfigError::PathError(e)) if e.source.kind() == NotFound => {
385 let (path, metadata) =
386 self.generate_initial_config(root_config_dir, &config_id)?;
387 LoadedSecureConfig {
388 config_file: Some(path),
389 metadata,
390 warnings: vec![CONFIG_NOT_FOUND.to_string()],
391 }
392 }
393 Err(e) => return Err(e),
394 }
395 }
396 Err(e) if e.source.kind() == NotFound => {
397 self.maybe_migrate_legacy_config(rng, root_config_dir)?
398 }
399 Err(e) => return Err(SecureConfigError::PathError(e)),
400 };
401 *self.cache.borrow_mut() = Some((loaded.config_file.clone(), loaded.metadata.clone()));
402 Ok(loaded)
403 }
404
405 pub fn load_config(
408 &self,
409 rng: &mut ChaCha20Rng,
410 root_config_dir: &Path,
411 ) -> Result<LoadedSecureConfig, SecureConfigError> {
412 let mut loaded = self.maybe_load_config(rng, root_config_dir)?;
413 if loaded.config_file.is_none() {
414 let (path, metadata) =
415 self.generate_initial_config(root_config_dir, &generate_config_id(rng))?;
416 *self.cache.borrow_mut() = Some((Some(path.clone()), metadata.clone()));
417 loaded.config_file = Some(path);
418 loaded.metadata = metadata;
419 }
420 Ok(loaded)
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use std::ffi::OsStr;
427
428 use rand::SeedableRng as _;
429 use tempfile::TempDir;
430 use test_case::test_case;
431
432 use super::*;
433 use crate::tests::TestResult;
434
435 struct TestEnv {
436 _td: TempDir,
437 rng: ChaCha20Rng,
438 config: SecureConfig,
439 repo_dir: PathBuf,
440 config_dir: PathBuf,
441 }
442
443 impl TestEnv {
444 fn new() -> Self {
445 let td = crate::tests::new_temp_dir();
446 let repo_dir = td.path().join("repo");
447 fs::create_dir(&repo_dir).unwrap();
448 let config_dir = td.path().join("config");
449 fs::create_dir(&config_dir).unwrap();
450 Self {
451 _td: td,
452 rng: ChaCha20Rng::seed_from_u64(0),
453 config: SecureConfig::new(repo_dir.clone(), "config-id", "legacy-config.toml"),
454 repo_dir,
455 config_dir,
456 }
457 }
458
459 fn secure_config_for_dir(&self, d: PathBuf) -> SecureConfig {
460 SecureConfig::new(d, "config-id", "legacy-config.toml")
461 }
462 }
463
464 #[test]
465 fn test_no_initial_config() -> TestResult {
466 let mut env = TestEnv::new();
467
468 let loaded = env
470 .config
471 .maybe_load_config(&mut env.rng, &env.config_dir)?;
472 assert_eq!(loaded.config_file, None);
473 assert_eq!(loaded.metadata, Default::default());
474 assert!(loaded.warnings.is_empty());
475 assert!(env.config.cache.borrow().is_some());
477
478 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
480 let path = loaded.config_file.unwrap();
481 let components: Vec<_> = path.components().rev().collect();
482 assert_eq!(
483 components[0],
484 std::path::Component::Normal(OsStr::new("config.toml"))
485 );
486 assert_eq!(
487 components[2],
488 std::path::Component::Normal(OsStr::new("config"))
489 );
490 assert!(!loaded.metadata.path.as_deref().unwrap().is_empty());
491 assert!(loaded.warnings.is_empty());
492
493 assert!(env.config.cache.borrow().is_some());
496 *env.config.cache.borrow_mut() = None;
497 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
498 assert_eq!(loaded2.config_file.unwrap(), path);
499 assert_eq!(loaded2.metadata, loaded.metadata);
500 assert!(loaded2.warnings.is_empty());
501 Ok(())
502 }
503
504 #[test]
505 fn test_migrate_legacy_config() -> TestResult {
506 let mut env = TestEnv::new();
507
508 let legacy_config = env.repo_dir.join("legacy-config.toml");
509 fs::write(&legacy_config, "config")?;
510 let loaded = env
511 .config
512 .maybe_load_config(&mut env.rng, &env.config_dir)?;
513 assert!(loaded.config_file.is_some());
514 assert!(!loaded.metadata.path.unwrap().is_empty());
515 let config_contents = fs::read_to_string(loaded.config_file.as_deref().unwrap())?;
516 assert_eq!(config_contents, "config");
517 assert!(!loaded.warnings.is_empty());
518
519 if cfg!(unix) {
521 fs::write(loaded.config_file.as_deref().unwrap(), "new")?;
522 let legacy_contents = fs::read_to_string(&legacy_config)?;
523 assert_eq!(legacy_contents, "new");
524 }
525 Ok(())
526 }
527
528 #[test]
529 fn test_repo_moved() -> TestResult {
530 let mut env = TestEnv::new();
531 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
532 let path = loaded.config_file.unwrap();
533
534 let dest = env.repo_dir.parent().unwrap().join("moved");
535 fs::rename(&env.repo_dir, &dest)?;
536 let config = env.secure_config_for_dir(dest);
537 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
538 assert_eq!(loaded2.config_file.unwrap(), path);
539 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
540 assert!(loaded2.warnings.is_empty());
541 Ok(())
542 }
543
544 #[test_case(None; "with empty metadata directory")]
545 #[test_case(Some(""); "with empty config file")]
546 #[test_case(Some("content"); "with non-empty config file")]
547 fn test_repo_copied(config_contents: Option<&str>) -> TestResult {
548 let mut env = TestEnv::new();
549 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
550 let path = loaded.config_file.unwrap();
551 if let Some(contents) = config_contents {
552 fs::write(&path, contents)?;
553 }
554
555 let dest = env.repo_dir.parent().unwrap().join("copied");
556 fs::create_dir(&dest)?;
557 fs::copy(env.repo_dir.join("config-id"), dest.join("config-id"))?;
558 let config = env.secure_config_for_dir(dest);
559 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
560 let path2 = loaded2.config_file.unwrap();
561 assert_ne!(path, path2);
562 if let Some(expected) = config_contents {
563 let path2_contents = fs::read_to_string(path2)?;
564 assert_eq!(path2_contents, expected);
565 } else {
566 assert!(!path2.exists());
567 }
568 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
569 assert!(!loaded2.warnings.is_empty());
571 Ok(())
572 }
573
574 #[cfg(unix)]
577 #[test]
578 fn test_repo_aliased() -> TestResult {
579 let mut env = TestEnv::new();
580 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
581 let path = loaded.config_file.unwrap();
582
583 let dest = env.repo_dir.parent().unwrap().join("copied");
584 std::os::unix::fs::symlink(&env.repo_dir, &dest)?;
585 let config = env.secure_config_for_dir(dest);
586 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
587 assert_eq!(loaded2.config_file.unwrap(), path);
588 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
589 assert!(loaded2.warnings.is_empty());
590 Ok(())
591 }
592
593 #[test]
594 fn test_missing_config() -> TestResult {
595 let mut env = TestEnv::new();
596 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
597 let path = loaded.config_file.unwrap();
598
599 fs::remove_dir_all(path.parent().unwrap())?;
600 *env.config.cache.borrow_mut() = None;
601
602 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
603 assert_eq!(loaded2.config_file.unwrap(), path);
604 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
605 assert!(path.parent().unwrap().is_dir());
607 assert!(!loaded2.warnings.is_empty());
608 Ok(())
609 }
610}