Skip to main content

jj_lib/
secure_config.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A mechanism to access config files for a repo securely.
16
17use 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/// A mechanism to access config files for a repo securely.
53#[derive(Clone, Debug)]
54pub struct SecureConfig {
55    /// Technically this is either a repo or a workspace.
56    repo_dir: PathBuf,
57    /// The name of the config id file.
58    config_id_name: &'static str,
59    /// The name of the legacy config file.
60    legacy_config_name: &'static str,
61    /// A cache of the output \[maybe_\]load_config
62    cache: RefCell<Option<(Option<PathBuf>, ConfigMetadata)>>,
63}
64
65/// An error when attempting to load config from disk.
66#[derive(Error, Debug)]
67pub enum SecureConfigError {
68    /// Failed to read / write to the specified path
69    #[error(transparent)]
70    PathError(#[from] PathError),
71
72    /// Failed to decode the user configuration file.
73    #[error(transparent)]
74    DecodeError(#[from] prost::DecodeError),
75
76    /// The path failed to decode to bytes.
77    #[error(transparent)]
78    BadPathEncoding(#[from] BadPathEncoding),
79
80    /// The config ID isn't CONFIG_ID_BYTES * 2 hex chars.
81    #[error("Found an invalid config ID")]
82    BadConfigIdError,
83}
84
85/// The path to the config file for a secure config.
86/// Also contains metadata about the repo and info to be displayed to the
87/// user.
88#[derive(Clone, Debug, Default)]
89pub struct LoadedSecureConfig {
90    /// The path to the config file.
91    /// Can be None if the config-ID is not generated.
92    pub config_file: Option<PathBuf>,
93    /// The metadata for the config.
94    pub metadata: ConfigMetadata,
95    /// Any warnings that we want to be reported to the user.
96    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
120/// Reads and decodes the `metadata.binpb` file in the given per-repo config
121/// directory.
122pub 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
128/// Returns the repo/workspace path stored in the metadata, if present.
129pub fn metadata_path(metadata: &ConfigMetadata) -> Result<Option<&Path>, BadPathEncoding> {
130    metadata.path.as_deref().map(path_from_bytes).transpose()
131}
132
133/// Removes the well-known files inside a per-repo config directory
134/// (`config.toml` and `metadata.binpb`) and then the directory itself.
135///
136/// The directory is removed non-recursively, so this errors out if any other
137/// file or subdirectory is present — better to leave a user-placed file in
138/// place than to silently delete it as part of a recursive `rm`.
139pub 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    /// Creates a secure config.
152    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    /// Creates a secure config for a repo. Takes the .jj/repo directory.
166    pub fn new_repo(repo_dir: PathBuf) -> Self {
167        Self::new(repo_dir, "config-id", "config.toml")
168    }
169
170    /// Creates a secure config for a workspace. Takes the .jj directory.
171    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        // Write the config ID atomically. A half-formed config ID would be very bad.
195        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    /// Validates that the metadata path matches the repo path.
215    /// If there's a mismatch, takes appropriate action.
216    /// Returns the updated config dir and metadata.
217    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                // The old repo does not exist. Assume the user moved it.
238                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        // We attempt to create a temporary file in the new repo.
248        // If it fails, we have readonly access to a repo, so we do nothing.
249        // If we write to the new repo and it shows up in the old one,
250        // we can skip this step, since it's not a copy.
251        if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
252            && !got.join(tmp.path().file_name().unwrap()).exists()
253        {
254            // We now assume the repo was copied. Since the repo was copied,
255            // the config should be copied too, rather than sharing the
256            // config with what it copied from.
257            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        // Make old versions and new versions of jj share the same config file.
296        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        // I considered making this readonly, but that would prevent you from
309        // updating the config with old versions of jj.
310        // In the future, we consider something a little more robust, where as
311        // the non-legacy config changes, we propagate that to the legacy config.
312        // However, it seems a little overkill, considering it only affects windows
313        // users who use multiple versions of jj at once, and only for a year.
314        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    /// Migrates the legacy config, if it exists.
321    fn maybe_migrate_legacy_config(
322        &self,
323        rng: &mut ChaCha20Rng,
324        root_config_dir: &Path,
325    ) -> Result<LoadedSecureConfig, SecureConfigError> {
326        // TODO: This function should be updated in jj 0.49 to no longer
327        // automatically migrate repos, but instead print out a warning.
328        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            // No legacy config files found.
332            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    /// Determines the path to the config, and any metadata associated with it.
358    /// If no config exists, the path will be None.
359    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    /// Determines the path to the config, and any metadata associated with it.
406    /// If no config exists, an empty config file will be generated.
407    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        // We shouldn't generate the config.
469        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        // The cache entry should be filled.
476        assert!(env.config.cache.borrow().is_some());
477
478        // load_config should generate the config if it previously didn't exist.
479        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        // load_config should leave it untouched if it did exist.
494        // Empty the cache to ensure the function is actually being tested
495        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        // On unix, it should be a symlink.
520        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        // We should get a warning about the repo having been copied.
570        assert!(!loaded2.warnings.is_empty());
571        Ok(())
572    }
573
574    // This feature works on windows as well, it just isn't easy to replicate with a
575    // test.
576    #[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        // It should have recreated the directory.
606        assert!(path.parent().unwrap().is_dir());
607        assert!(!loaded2.warnings.is_empty());
608        Ok(())
609    }
610}