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::Rng 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
120impl SecureConfig {
121 fn new(
123 repo_dir: PathBuf,
124 config_id_name: &'static str,
125 legacy_config_name: &'static str,
126 ) -> Self {
127 Self {
128 repo_dir,
129 config_id_name,
130 legacy_config_name,
131 cache: RefCell::new(None),
132 }
133 }
134
135 pub fn new_repo(repo_dir: PathBuf) -> Self {
137 Self::new(repo_dir, "config-id", "config.toml")
138 }
139
140 pub fn new_workspace(workspace_dir: PathBuf) -> Self {
142 Self::new(
143 workspace_dir,
144 "workspace-config-id",
145 "workspace-config.toml",
146 )
147 }
148
149 fn generate_config(
150 &self,
151 root_config_dir: &Path,
152 config_id: &str,
153 content: Option<&[u8]>,
154 metadata: &ConfigMetadata,
155 ) -> Result<PathBuf, SecureConfigError> {
156 let config_dir = root_config_dir.join(config_id);
157 let config_path = config_dir.join(CONFIG_FILE);
158 fs::create_dir_all(&config_dir).context(&config_dir)?;
159 update_metadata(&config_dir, metadata)?;
160 if let Some(content) = content {
161 fs::write(&config_path, content).context(&config_path)?;
162 }
163
164 atomic_write(
166 &self.repo_dir.join(self.config_id_name),
167 config_id.as_bytes(),
168 )?;
169 Ok(config_path)
170 }
171
172 fn generate_initial_config(
173 &self,
174 root_config_dir: &Path,
175 config_id: &str,
176 ) -> Result<(PathBuf, ConfigMetadata), SecureConfigError> {
177 let metadata = ConfigMetadata {
178 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
179 };
180 let path = self.generate_config(root_config_dir, config_id, None, &metadata)?;
181 Ok((path, metadata))
182 }
183
184 fn handle_metadata_path(
188 &self,
189 rng: &mut ChaCha20Rng,
190 root_config_dir: &Path,
191 config_dir: PathBuf,
192 mut metadata: ConfigMetadata,
193 ) -> Result<LoadedSecureConfig, SecureConfigError> {
194 let encoded = path_to_bytes(&self.repo_dir).ok();
195 let got = metadata.path.as_deref().map(path_from_bytes).transpose()?;
196
197 if got == encoded.is_some().then_some(self.repo_dir.as_path()) {
198 return Ok(LoadedSecureConfig {
199 config_file: Some(config_dir.join(CONFIG_FILE)),
200 metadata,
201 warnings: vec![],
202 });
203 }
204 let got = match got {
205 Some(d) if d.is_dir() => d.to_path_buf(),
206 _ => {
207 metadata.path = encoded.map(|b| b.to_vec());
209 update_metadata(&config_dir, &metadata)?;
210 return Ok(LoadedSecureConfig {
211 config_file: Some(config_dir.join(CONFIG_FILE)),
212 metadata,
213 warnings: vec![],
214 });
215 }
216 };
217 if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
222 && !got.join(tmp.path().file_name().unwrap()).exists()
223 {
224 let old_config_path = config_dir.join(CONFIG_FILE);
228 metadata.path = encoded.map(|b| b.to_vec());
229 let old_config_content = fs::read(&old_config_path).context(&old_config_path)?;
230 let config_path = self.generate_config(
231 root_config_dir,
232 &generate_config_id(rng),
233 Some(&old_config_content),
234 &metadata,
235 )?;
236 return Ok(LoadedSecureConfig {
237 config_file: Some(config_path.clone()),
238 metadata,
239 warnings: vec![format!(
240 "Your repo appears to have been copied from {} to {}. The corresponding repo \
241 config file has also been copied.",
242 got.display(),
243 &self.repo_dir.display()
244 )],
245 });
246 }
247 Ok(LoadedSecureConfig {
248 config_file: Some(config_dir.join(CONFIG_FILE)),
249 metadata,
250 warnings: vec![],
251 })
252 }
253
254 #[cfg(unix)]
255 fn update_legacy_config_file(
256 &self,
257 new_config: &Path,
258 _content: &[u8],
259 ) -> Result<(), SecureConfigError> {
260 let legacy_config = self.repo_dir.join(self.legacy_config_name);
261 fs::remove_file(&legacy_config).context(&legacy_config)?;
263 std::os::unix::fs::symlink(new_config, &legacy_config).context(&legacy_config)?;
264 Ok(())
265 }
266
267 #[cfg(not(unix))]
268 fn update_legacy_config_file(
269 &self,
270 _new_config: &Path,
271 content: &[u8],
272 ) -> Result<(), SecureConfigError> {
273 let legacy_config = self.repo_dir.join(self.legacy_config_name);
274 let mut new_content = CONTENT_PREFIX.as_bytes().to_vec();
281 new_content.extend_from_slice(content);
282 fs::write(&legacy_config, new_content).context(&legacy_config)?;
283 Ok(())
284 }
285
286 fn maybe_migrate_legacy_config(
288 &self,
289 rng: &mut ChaCha20Rng,
290 root_config_dir: &Path,
291 ) -> Result<LoadedSecureConfig, SecureConfigError> {
292 let legacy_config = self.repo_dir.join(self.legacy_config_name);
295 let config = match fs::read(&legacy_config).context(&legacy_config) {
296 Ok(config_content) => config_content,
297 Err(e) if e.source.kind() == NotFound => return Ok(Default::default()),
299 Err(e) => return Err(e.into()),
300 };
301 let metadata = ConfigMetadata {
302 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
303 };
304 let config_file = self.generate_config(
305 root_config_dir,
306 &generate_config_id(rng),
307 Some(&config),
308 &metadata,
309 )?;
310 self.update_legacy_config_file(&config_file, &config)?;
311 Ok(LoadedSecureConfig {
312 warnings: vec![format!(
313 "Your config file has been migrated from {} to {}. You can edit the new file with \
314 `jj config edit`",
315 legacy_config.display(),
316 config_file.display(),
317 )],
318 config_file: Some(config_file),
319 metadata,
320 })
321 }
322
323 pub fn maybe_load_config(
326 &self,
327 rng: &mut ChaCha20Rng,
328 root_config_dir: &Path,
329 ) -> Result<LoadedSecureConfig, SecureConfigError> {
330 if let Some(cache) = self.cache.borrow().as_ref() {
331 return Ok(LoadedSecureConfig {
332 config_file: cache.0.clone(),
333 metadata: cache.1.clone(),
334 warnings: vec![],
335 });
336 }
337 let config_id_path = self.repo_dir.join(self.config_id_name);
338 let loaded = match fs::read_to_string(&config_id_path).context(&config_id_path) {
339 Ok(config_id) => {
340 if config_id.len() != CONFIG_ID_BYTES * 2
341 || !config_id.chars().all(|c| c.is_ascii_hexdigit())
342 {
343 return Err(SecureConfigError::BadConfigIdError);
344 }
345 let config_dir = root_config_dir.join(&config_id);
346 let metadata_path = config_dir.join(METADATA_FILE);
347 match fs::read(&metadata_path).context(&metadata_path) {
348 Ok(buf) => self.handle_metadata_path(
349 rng,
350 root_config_dir,
351 config_dir,
352 ConfigMetadata::decode(buf.as_slice())?,
353 )?,
354 Err(e) if e.source.kind() == NotFound => {
355 let (path, metadata) =
356 self.generate_initial_config(root_config_dir, &config_id)?;
357 LoadedSecureConfig {
358 config_file: Some(path),
359 metadata,
360 warnings: vec![CONFIG_NOT_FOUND.to_string()],
361 }
362 }
363 Err(e) => return Err(e.into()),
364 }
365 }
366 Err(e) if e.source.kind() == NotFound => {
367 self.maybe_migrate_legacy_config(rng, root_config_dir)?
368 }
369 Err(e) => return Err(SecureConfigError::PathError(e)),
370 };
371 *self.cache.borrow_mut() = Some((loaded.config_file.clone(), loaded.metadata.clone()));
372 Ok(loaded)
373 }
374
375 pub fn load_config(
378 &self,
379 rng: &mut ChaCha20Rng,
380 root_config_dir: &Path,
381 ) -> Result<LoadedSecureConfig, SecureConfigError> {
382 let mut loaded = self.maybe_load_config(rng, root_config_dir)?;
383 if loaded.config_file.is_none() {
384 let (path, metadata) =
385 self.generate_initial_config(root_config_dir, &generate_config_id(rng))?;
386 *self.cache.borrow_mut() = Some((Some(path.clone()), metadata.clone()));
387 loaded.config_file = Some(path);
388 loaded.metadata = metadata;
389 }
390 Ok(loaded)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use std::ffi::OsStr;
397
398 use rand::SeedableRng as _;
399 use tempfile::TempDir;
400
401 use super::*;
402
403 struct TestEnv {
404 _td: TempDir,
405 rng: ChaCha20Rng,
406 config: SecureConfig,
407 repo_dir: PathBuf,
408 config_dir: PathBuf,
409 }
410
411 impl TestEnv {
412 fn new() -> Self {
413 let td = crate::tests::new_temp_dir();
414 let repo_dir = td.path().join("repo");
415 fs::create_dir(&repo_dir).unwrap();
416 let config_dir = td.path().join("config");
417 fs::create_dir(&config_dir).unwrap();
418 Self {
419 _td: td,
420 rng: ChaCha20Rng::seed_from_u64(0),
421 config: SecureConfig::new(repo_dir.clone(), "config-id", "legacy-config.toml"),
422 repo_dir,
423 config_dir,
424 }
425 }
426
427 fn secure_config_for_dir(&self, d: PathBuf) -> SecureConfig {
428 SecureConfig::new(d, "config-id", "legacy-config.toml")
429 }
430 }
431
432 #[test]
433 fn test_no_initial_config() {
434 let mut env = TestEnv::new();
435
436 let loaded = env
438 .config
439 .maybe_load_config(&mut env.rng, &env.config_dir)
440 .unwrap();
441 assert_eq!(loaded.config_file, None);
442 assert_eq!(loaded.metadata, Default::default());
443 assert!(loaded.warnings.is_empty());
444 assert!(env.config.cache.borrow().is_some());
446
447 let loaded = env
449 .config
450 .load_config(&mut env.rng, &env.config_dir)
451 .unwrap();
452 let path = loaded.config_file.unwrap();
453 let components: Vec<_> = path.components().rev().collect();
454 assert_eq!(
455 components[0],
456 std::path::Component::Normal(OsStr::new("config.toml"))
457 );
458 assert_eq!(
459 components[2],
460 std::path::Component::Normal(OsStr::new("config"))
461 );
462 assert!(!loaded.metadata.path.as_deref().unwrap().is_empty());
463 assert!(loaded.warnings.is_empty());
464
465 assert!(env.config.cache.borrow().is_some());
468 *env.config.cache.borrow_mut() = None;
469 let loaded2 = env
470 .config
471 .load_config(&mut env.rng, &env.config_dir)
472 .unwrap();
473 assert_eq!(loaded2.config_file.unwrap(), path);
474 assert_eq!(loaded2.metadata, loaded.metadata);
475 assert!(loaded2.warnings.is_empty());
476 }
477
478 #[test]
479 fn test_migrate_legacy_config() {
480 let mut env = TestEnv::new();
481
482 let legacy_config = env.repo_dir.join("legacy-config.toml");
483 fs::write(&legacy_config, "config").unwrap();
484 let loaded = env
485 .config
486 .maybe_load_config(&mut env.rng, &env.config_dir)
487 .unwrap();
488 assert!(loaded.config_file.is_some());
489 assert!(!loaded.metadata.path.unwrap().is_empty());
490 assert_eq!(
491 fs::read_to_string(loaded.config_file.as_deref().unwrap()).unwrap(),
492 "config"
493 );
494 assert!(!loaded.warnings.is_empty());
495
496 if cfg!(unix) {
498 fs::write(loaded.config_file.as_deref().unwrap(), "new").unwrap();
499 assert_eq!(fs::read_to_string(&legacy_config).unwrap(), "new");
500 }
501 }
502
503 #[test]
504 fn test_repo_moved() {
505 let mut env = TestEnv::new();
506 let loaded = env
507 .config
508 .load_config(&mut env.rng, &env.config_dir)
509 .unwrap();
510 let path = loaded.config_file.unwrap();
511
512 let dest = env.repo_dir.parent().unwrap().join("moved");
513 fs::rename(&env.repo_dir, &dest).unwrap();
514 let config = env.secure_config_for_dir(dest);
515 let loaded2 = config.load_config(&mut env.rng, &env.config_dir).unwrap();
516 assert_eq!(loaded2.config_file.unwrap(), path);
517 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
518 assert!(loaded2.warnings.is_empty());
519 }
520
521 #[test]
522 fn test_repo_copied() {
523 let mut env = TestEnv::new();
524 let loaded = env
525 .config
526 .load_config(&mut env.rng, &env.config_dir)
527 .unwrap();
528 let path = loaded.config_file.unwrap();
529 fs::write(&path, "config").unwrap();
530
531 let dest = env.repo_dir.parent().unwrap().join("copied");
532 fs::create_dir(&dest).unwrap();
533 fs::copy(env.repo_dir.join("config-id"), dest.join("config-id")).unwrap();
534 let config = env.secure_config_for_dir(dest);
535 let loaded2 = config.load_config(&mut env.rng, &env.config_dir).unwrap();
536 let path2 = loaded2.config_file.unwrap();
537 assert_ne!(path, path2);
538 assert_eq!(fs::read_to_string(path2).unwrap(), "config");
539 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
540 assert!(!loaded2.warnings.is_empty());
542 }
543
544 #[cfg(unix)]
547 #[test]
548 fn test_repo_aliased() {
549 let mut env = TestEnv::new();
550 let loaded = env
551 .config
552 .load_config(&mut env.rng, &env.config_dir)
553 .unwrap();
554 let path = loaded.config_file.unwrap();
555
556 let dest = env.repo_dir.parent().unwrap().join("copied");
557 std::os::unix::fs::symlink(&env.repo_dir, &dest).unwrap();
558 let config = env.secure_config_for_dir(dest);
559 let loaded2 = config.load_config(&mut env.rng, &env.config_dir).unwrap();
560 assert_eq!(loaded2.config_file.unwrap(), path);
561 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
562 assert!(loaded2.warnings.is_empty());
563 }
564
565 #[test]
566 fn test_missing_config() {
567 let mut env = TestEnv::new();
568 let loaded = env
569 .config
570 .load_config(&mut env.rng, &env.config_dir)
571 .unwrap();
572 let path = loaded.config_file.unwrap();
573
574 fs::remove_dir_all(path.parent().unwrap()).unwrap();
575 *env.config.cache.borrow_mut() = None;
576
577 let loaded2 = env
578 .config
579 .load_config(&mut env.rng, &env.config_dir)
580 .unwrap();
581 assert_eq!(loaded2.config_file.unwrap(), path);
582 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
583 assert!(path.parent().unwrap().is_dir());
585 assert!(!loaded2.warnings.is_empty());
586 }
587}