Skip to content

Commit fb86c94

Browse files
antiguruclaude
andauthored
mz: don't require write access to load an existing config file (#37566)
### Motivation `mz` fails on read-only commands (e.g. `mz sql`) when `mz.toml` sits on a read-only mount, such as a sandbox that bind-mounts the config file read-only. The command never intends to write anything, so this is an unnecessary failure. ### Description `ConfigFile::load` always opened `mz.toml` with `write(true).create(true)`, even though every mutation (`add_profile`, `remove_profile`, `set_param`, `set_profile_param`) writes the file independently afterward via `fs::write`. The write-mode open at load time was only ever needed to create the file when missing. `load` no longer writes to the file system at all. A missing file loads as an empty configuration, and the first mutation creates whatever is missing. Since `fs::write` creates the file but not its parent directory, all mutations now go through a private `ConfigFile::write` helper that calls `create_dir_all` first, taking over the directory creation that `load` used to perform. Read-only commands therefore work both when `mz.toml` sits on a read-only mount and when it does not exist yet. Dropping the write-mode open changes when an unwritable configuration is reported. `mz profile init` creates a remote app password, and on macOS a keychain entry, before `add_profile` reaches its write. Failing only at that write would leave a credential behind that no profile records, so `ConfigFile::ensure_writable` establishes write access and `init` calls it before logging in. The check creates the parent directory and opens the file with `create(true)`, because whether a missing file can be written depends on the parent hierarchy and no probe settles that as reliably as performing the creation the later write needs anyway. Unit tests cover loading a missing file, loading a read-only file, a missing file inside a read-only directory, and the parent directory creation that moved out of `load`. The permission-dependent assertions skip themselves when the file system does not enforce the permission bits for the process, as is the case under root. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 24f2613 commit fb86c94

5 files changed

Lines changed: 173 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/mz/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ url.workspace = true
4444

4545
[dev-dependencies]
4646
assert_cmd.workspace = true
47+
tempfile.workspace = true
4748

4849
[target.'cfg(target_os = "macos")'.dependencies]
4950
security-framework = "3.7.0"

src/mz/src/command/profile.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ pub async fn init(
170170
}
171171
}
172172

173+
// Fail before logging in, so that an unwritable configuration file doesn't
174+
// leave behind an app password that no profile records.
175+
config_file.ensure_writable().await?;
176+
173177
let app_password = match no_browser {
174178
true => init_without_browser(admin_endpoint.clone()).await?,
175179
false => init_with_browser(cloud_endpoint.clone()).await?,

src/mz/src/config_file.rs

Lines changed: 162 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515

1616
//! Configuration file management.
1717
18-
use std::fs::OpenOptions;
19-
use std::io::Read;
2018
use std::path::PathBuf;
2119
use std::sync::LazyLock;
2220
use std::{collections::BTreeMap, str::FromStr};
@@ -84,24 +82,17 @@ impl ConfigFile {
8482
}
8583

8684
/// Loads a configuration file from the specified path.
85+
///
86+
/// A missing file loads as an empty configuration. Loading never creates
87+
/// the file or its parent directory, and never requires write access, so
88+
/// commands that only read the configuration work when `mz.toml` lives on
89+
/// a read-only mount. The first mutation creates whatever is missing.
8790
pub async fn load(path: PathBuf) -> Result<ConfigFile, Error> {
88-
// Create the parent directory if it doesn't exist
89-
if let Some(parent) = path.parent() {
90-
if !parent.exists() {
91-
fs::create_dir_all(parent).await?;
92-
}
93-
}
94-
95-
// Create the file if it doesn't exist
96-
let mut file = OpenOptions::new()
97-
.read(true)
98-
.write(true)
99-
.create(true)
100-
.truncate(false)
101-
.open(&path)?;
102-
103-
let mut buffer = String::new();
104-
file.read_to_string(&mut buffer)?;
91+
let buffer = match fs::read_to_string(&path).await {
92+
Ok(buffer) => buffer,
93+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
94+
Err(e) => return Err(e.into()),
95+
};
10596

10697
let parsed = toml_edit::de::from_str(&buffer)?;
10798
let editable = buffer.parse()?;
@@ -113,6 +104,50 @@ impl ConfigFile {
113104
})
114105
}
115106

107+
/// Writes `contents` to the configuration file, creating the parent
108+
/// directory if it doesn't exist.
109+
async fn write(&self, contents: String) -> Result<(), Error> {
110+
if let Some(parent) = self.path.parent() {
111+
fs::create_dir_all(parent).await?;
112+
}
113+
fs::write(&self.path, contents).await?;
114+
115+
Ok(())
116+
}
117+
118+
/// Errors unless the configuration file can be written, creating the file
119+
/// and its parent directory if they are missing.
120+
///
121+
/// Commands that cause external side effects before mutating the
122+
/// configuration, such as creating an app password or writing to the
123+
/// keychain, must call this beforehand. Otherwise an unwritable
124+
/// configuration file fails the command only after those side effects have
125+
/// happened, leaving them unrecorded.
126+
///
127+
/// Creating what is missing is what makes the check conclusive: whether a
128+
/// missing file can be written depends on the parent hierarchy, which no
129+
/// amount of probing establishes as reliably as performing the creation
130+
/// that the later write needs anyway.
131+
pub async fn ensure_writable(&self) -> Result<(), Error> {
132+
let result = async {
133+
if let Some(parent) = self.path.parent() {
134+
fs::create_dir_all(parent).await?;
135+
}
136+
fs::OpenOptions::new()
137+
.write(true)
138+
.create(true)
139+
.truncate(false)
140+
.open(&self.path)
141+
.await?;
142+
143+
Ok::<(), std::io::Error>(())
144+
};
145+
146+
result
147+
.await
148+
.map_err(|e| Error::ConfigFileNotWritable(self.path.clone(), e))
149+
}
150+
116151
/// Loads a profile from the configuration file.
117152
/// Panics if the profile is not found.
118153
pub fn load_profile<'a>(&'a self, name: &'a str) -> Result<Profile<'a>, Error> {
@@ -161,7 +196,7 @@ impl ConfigFile {
161196
editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
162197

163198
// TODO: I don't know why it creates an empty [profiles] table
164-
fs::write(&self.path, editable.to_string()).await?;
199+
self.write(editable.to_string()).await?;
165200

166201
Ok(())
167202
}
@@ -209,7 +244,7 @@ impl ConfigFile {
209244
.ok_or(Error::ProfilesMissing)?;
210245
profiles.remove(name);
211246

212-
fs::write(&self.path, editable.to_string()).await?;
247+
self.write(editable.to_string()).await?;
213248

214249
Ok(())
215250
}
@@ -290,7 +325,7 @@ impl ConfigFile {
290325
Some(value) => editable["profiles"][profile_name][name] = toml_edit::value(value),
291326
}
292327

293-
fs::write(&self.path, editable.to_string()).await?;
328+
self.write(editable.to_string()).await?;
294329

295330
Ok(())
296331
}
@@ -324,7 +359,7 @@ impl ConfigFile {
324359
}
325360
Some(value) => editable[name] = toml_edit::value(value),
326361
}
327-
fs::write(&self.path, editable.to_string()).await?;
362+
self.write(editable.to_string()).await?;
328363
Ok(())
329364
}
330365
}
@@ -530,3 +565,107 @@ pub struct TomlProfile {
530565
/// A custom cloud endpoint used for development.
531566
pub cloud_endpoint: Option<String>,
532567
}
568+
569+
#[cfg(test)]
570+
mod tests {
571+
use tempfile::TempDir;
572+
573+
use super::*;
574+
575+
/// Returns whether the file system enforces `path`'s permission bits for
576+
/// this process, which is not the case when running as root.
577+
fn permissions_are_enforced(path: &PathBuf) -> bool {
578+
std::fs::OpenOptions::new().write(true).open(path).is_err()
579+
}
580+
581+
/// Makes `path` read-only and returns whether the file system enforces
582+
/// that for this process, which is not the case when running as root.
583+
fn make_read_only(path: &PathBuf) -> bool {
584+
let mut permissions = std::fs::metadata(path).unwrap().permissions();
585+
permissions.set_readonly(true);
586+
std::fs::set_permissions(path, permissions).unwrap();
587+
588+
if path.is_dir() {
589+
let probe = path.join("probe");
590+
let enforced = std::fs::write(&probe, "").is_err();
591+
let _ = std::fs::remove_file(probe);
592+
enforced
593+
} else {
594+
permissions_are_enforced(path)
595+
}
596+
}
597+
598+
/// Restores write access so that the temporary directory can be cleaned up.
599+
fn make_writable(path: &PathBuf) {
600+
let mut permissions = std::fs::metadata(path).unwrap().permissions();
601+
#[allow(clippy::permissions_set_readonly_false)]
602+
permissions.set_readonly(false);
603+
std::fs::set_permissions(path, permissions).unwrap();
604+
}
605+
606+
#[mz_ore::test(tokio::test)]
607+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `mkdir`
608+
async fn test_load_missing_file() {
609+
let dir = TempDir::new().unwrap();
610+
let path = dir.path().join("missing").join("mz.toml");
611+
612+
let config = ConfigFile::load(path.clone()).await.unwrap();
613+
614+
assert!(config.profiles().is_none());
615+
// Loading must not create anything on disk.
616+
assert!(!path.exists());
617+
assert!(!path.parent().unwrap().exists());
618+
}
619+
620+
#[mz_ore::test(tokio::test)]
621+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `mkdir`
622+
async fn test_load_read_only_file() {
623+
let dir = TempDir::new().unwrap();
624+
let path = dir.path().join("mz.toml");
625+
std::fs::write(&path, "profile = \"default\"\n").unwrap();
626+
let enforced = make_read_only(&path);
627+
628+
let config = ConfigFile::load(path.clone()).await.unwrap();
629+
630+
assert_eq!(config.profile(), "default");
631+
if enforced {
632+
assert!(config.ensure_writable().await.is_err());
633+
}
634+
635+
make_writable(&path);
636+
}
637+
638+
#[mz_ore::test(tokio::test)]
639+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `mkdir`
640+
async fn test_missing_file_in_read_only_directory() {
641+
let dir = TempDir::new().unwrap();
642+
let parent = dir.path().join("materialize");
643+
std::fs::create_dir(&parent).unwrap();
644+
let enforced = make_read_only(&parent);
645+
646+
let config = ConfigFile::load(parent.join("mz.toml")).await.unwrap();
647+
648+
// A missing file is only writable if its parent hierarchy accepts it.
649+
if enforced {
650+
assert!(config.ensure_writable().await.is_err());
651+
}
652+
653+
make_writable(&parent);
654+
}
655+
656+
#[mz_ore::test(tokio::test)]
657+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `mkdir`
658+
async fn test_write_creates_parent_directory() {
659+
let dir = TempDir::new().unwrap();
660+
let path = dir.path().join("materialize").join("mz.toml");
661+
662+
let config = ConfigFile::load(path.clone()).await.unwrap();
663+
config.ensure_writable().await.unwrap();
664+
// The writability check creates what the later write needs.
665+
assert!(path.exists());
666+
667+
config.set_param("profile", Some("default")).await.unwrap();
668+
669+
assert_eq!(ConfigFile::load(path).await.unwrap().profile(), "default");
670+
}
671+
}

src/mz/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
//! [`Error`](`enum@Error`) is a custom error type containing multiple variants
1717
//! for erros produced by the self crate, internal crates and external crates.
1818
19+
use std::path::PathBuf;
20+
1921
use hyper::header::{InvalidHeaderValue, ToStrError};
2022
use thiserror::Error;
2123
use url::ParseError;
@@ -88,6 +90,9 @@ pub enum Error {
8890
/// I/O Error
8991
#[error(transparent)]
9092
IOError(#[from] std::io::Error),
93+
/// Error raised when the configuration file exists but cannot be written.
94+
#[error("Error: The configuration file {0} is not writable: {1}")]
95+
ConfigFileNotWritable(PathBuf, #[source] std::io::Error),
9196
/// I/O Error
9297
#[error(transparent)]
9398
CSVParseError(#[from] csv::Error),

0 commit comments

Comments
 (0)