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::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/// 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
120impl SecureConfig {
121    /// Creates a secure config.
122    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    /// Creates a secure config for a repo. Takes the .jj/repo directory.
136    pub fn new_repo(repo_dir: PathBuf) -> Self {
137        Self::new(repo_dir, "config-id", "config.toml")
138    }
139
140    /// Creates a secure config for a workspace. Takes the .jj directory.
141    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        // Write the config ID atomically. A half-formed config ID would be very bad.
165        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    /// Validates that the metadata path matches the repo path.
185    /// If there's a mismatch, takes appropriate action.
186    /// Returns the updated config dir and metadata.
187    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                // The old repo does not exist. Assume the user moved it.
208                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        // We attempt to create a temporary file in the new repo.
218        // If it fails, we have readonly access to a repo, so we do nothing.
219        // If we write to the new repo and it shows up in the old one,
220        // we can skip this step, since it's not a copy.
221        if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
222            && !got.join(tmp.path().file_name().unwrap()).exists()
223        {
224            // We now assume the repo was copied. Since the repo was copied,
225            // the config should be copied too, rather than sharing the
226            // config with what it copied from.
227            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        // Make old versions and new versions of jj share the same config file.
262        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        // I considered making this readonly, but that would prevent you from
275        // updating the config with old versions of jj.
276        // In the future, we consider something a little more robust, where as
277        // the non-legacy config changes, we propagate that to the legacy config.
278        // However, it seems a little overkill, considering it only affects windows
279        // users who use multiple versions of jj at once, and only for a year.
280        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    /// Migrates the legacy config, if it exists.
287    fn maybe_migrate_legacy_config(
288        &self,
289        rng: &mut ChaCha20Rng,
290        root_config_dir: &Path,
291    ) -> Result<LoadedSecureConfig, SecureConfigError> {
292        // TODO: This function should be updated in jj 0.49 to no longer
293        // automatically migrate repos, but instead print out a warning.
294        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            // No legacy config files found.
298            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    /// Determines the path to the config, and any metadata associated with it.
324    /// If no config exists, the path will be None.
325    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    /// Determines the path to the config, and any metadata associated with it.
376    /// If no config exists, an empty config file will be generated.
377    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        // We shouldn't generate the config.
437        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        // The cache entry should be filled.
445        assert!(env.config.cache.borrow().is_some());
446
447        // load_config should generate the config if it previously didn't exist.
448        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        // load_config should leave it untouched if it did exist.
466        // Empty the cache to ensure the function is actually being tested
467        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        // On unix, it should be a symlink.
497        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        // We should get a warning about the repo having been copied.
541        assert!(!loaded2.warnings.is_empty());
542    }
543
544    // This feature works on windows as well, it just isn't easy to replicate with a
545    // test.
546    #[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        // It should have recreated the directory.
584        assert!(path.parent().unwrap().is_dir());
585        assert!(!loaded2.warnings.is_empty());
586    }
587}