mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Allow NFS database migration with shared file permissions (#57226)
## Why NFS database migration rejected files with group or other access. Allow accessible databases to migrate while making the effects on file access explicit. ## What changed - Preserve file mode bits during migration while requiring owner read and write permissions on replacement files. - Before CLI consent, disclose that replacements belong to the current user and do not copy the original owner, group, or extended ACLs. Require explicit confirmation for files with permissions other than `0600`. - Warn at Linux TUI startup when NFS databases need migration and permission confirmation. Inspect journal mode without accessing WAL shared memory or releasing another connection's locks. - Allow five seconds to acquire the exclusive database lock during migration. ## Testing Add CLI coverage for consent, access disclosures, and migration of `0644` files. Extend state tests for permission preservation, rejection of missing owner access, and interrupted migration recovery; snapshot the startup warning. GitOrigin-RevId: 2d9c3663172c3e631a787dc1dc76db95c39e6aa1
This commit is contained in:
committed by
copyberry
parent
480ab2d006
commit
ca8b7a0737
Generated
-1
@@ -4802,7 +4802,6 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"file-id",
|
||||
"libc",
|
||||
"libsqlite3-sys",
|
||||
"log",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -42,10 +42,6 @@ pub(crate) async fn run(
|
||||
eprintln!(
|
||||
"Stop all Codex instances on every host that uses these databases.\nKeep them stopped until the migration completes successfully."
|
||||
);
|
||||
ensure!(
|
||||
crate::confirm("Continue? [y/N]:")?,
|
||||
"Migration cancelled. No databases were changed."
|
||||
);
|
||||
let config = ConfigBuilder::default()
|
||||
.cli_overrides(
|
||||
config_overrides
|
||||
@@ -63,6 +59,31 @@ pub(crate) async fn run(
|
||||
println!("No databases found.");
|
||||
return Ok(());
|
||||
}
|
||||
let mut needs_permission_confirmation = false;
|
||||
for database in sqlite.runtime_db_paths() {
|
||||
if skip_logs && database.path == sqlite.logs_db_path() {
|
||||
continue;
|
||||
}
|
||||
needs_permission_confirmation |=
|
||||
NfsDbMigration::needs_permission_confirmation(&database.path)?;
|
||||
}
|
||||
if needs_permission_confirmation {
|
||||
eprintln!(
|
||||
"Some database files have permissions other than 0600.\nMigration preserves file mode bits, including group and other access."
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"Replacement files belong to you; the original owner, group, and extended ACLs are not copied.\nThis can remove another user's access even when file permissions are 0600.\nOriginal files remain in a private backup directory.\nYou need write access to the database directory and its migration lock."
|
||||
);
|
||||
let prompt = if needs_permission_confirmation {
|
||||
"Migrate databases, including files with permissions other than 0600? [y/N]:"
|
||||
} else {
|
||||
"Continue? [y/N]:"
|
||||
};
|
||||
ensure!(
|
||||
crate::confirm(prompt)?,
|
||||
"Migration cancelled. No databases were changed."
|
||||
);
|
||||
let mut migration = NfsDbMigration::lock(sqlite.home())?;
|
||||
for database in sqlite.runtime_db_paths() {
|
||||
if skip_logs && database.path == sqlite.logs_db_path() {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Exercise consent and file permissions through the migration CLI.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_state::SqliteConfig;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_requires_consent_and_preserves_shared_database_mode() -> Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
let sqlite = SqliteConfig::new_for_testing(home.path().to_path_buf().try_into()?);
|
||||
let path = sqlite.state_db_path();
|
||||
let pool = sqlite.open_read_write_pool(&path).await?;
|
||||
sqlx::raw_sql("CREATE TABLE entries (id INTEGER); INSERT INTO entries VALUES (42)")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
pool.close().await;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?;
|
||||
let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
let declined = command
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env(codex_state::SQLITE_HOME_ENV, home.path())
|
||||
.arg("migrate-nfs-db")
|
||||
.write_stdin("n\n")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("permissions other than 0600").not())
|
||||
.stderr(contains("Continue? [y/N]"));
|
||||
let stderr = String::from_utf8_lossy(&declined.get_output().stderr);
|
||||
let disclosure = stderr
|
||||
.find("the original owner, group, and extended ACLs are not copied")
|
||||
.unwrap();
|
||||
let consent = stderr.find("Continue? [y/N]").unwrap();
|
||||
assert!(
|
||||
disclosure < consent,
|
||||
"ACL loss must be disclosed before consent, including for 0600 files"
|
||||
);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(/*mode*/ 0o644))?;
|
||||
let before = std::fs::read(&path)?;
|
||||
|
||||
let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
command
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env(codex_state::SQLITE_HOME_ENV, home.path())
|
||||
.arg("migrate-nfs-db")
|
||||
.write_stdin("n\n")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("permissions other than 0600"))
|
||||
.stderr(contains("No databases were changed"));
|
||||
assert_eq!(std::fs::read(&path)?, before);
|
||||
|
||||
let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
command
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env(codex_state::SQLITE_HOME_ENV, home.path())
|
||||
.arg("migrate-nfs-db")
|
||||
.write_stdin("y\n")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Migration completed successfully"));
|
||||
let pool = sqlite
|
||||
.open_read_only_pool(&path, /*busy_timeout*/ None)
|
||||
.await?;
|
||||
let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let rows: Vec<i64> = sqlx::query_scalar("SELECT id FROM entries")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
mode,
|
||||
rows,
|
||||
std::fs::metadata(&path)?.permissions().mode() & 0o777
|
||||
),
|
||||
("delete".to_string(), vec![42], 0o644)
|
||||
);
|
||||
pool.close().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1052,6 +1052,8 @@ pub struct ConfigRequirementsToml {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
pub sqlite_home: Option<AbsolutePathBuf>,
|
||||
/// Require migration of existing WAL databases on NFS before opening writable pools.
|
||||
/// Unsafe: other clients on all hosts must remain stopped during replacement.
|
||||
/// Allows accessible shared files; mode bits are preserved, but ownership and ACLs are not.
|
||||
pub unsafe_enforce_sqlite_journal_mode: Option<bool>,
|
||||
pub log_dir: Option<AbsolutePathBuf>,
|
||||
pub model_catalog_json: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -12,7 +12,6 @@ codex-history = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
file-id = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Converts WAL databases to DELETE mode while preserving recoverable WAL backups.
|
||||
//! Callers serialize startup and migration with the directory lock. Other SQLite clients
|
||||
//! must remain stopped during replacement; NFS must enforce cross-client POSIX locks.
|
||||
//! Migration requires write access to the directory and its persistent migration lock.
|
||||
//! Relaxed file permissions do not establish cross-user database sharing.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::ensure;
|
||||
@@ -19,8 +21,6 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod access;
|
||||
mod replacement;
|
||||
|
||||
use replacement::Recovery;
|
||||
@@ -40,6 +40,76 @@ pub enum NfsDbMigrationOutcome {
|
||||
}
|
||||
|
||||
impl NfsDbMigration {
|
||||
/// Inspect the on-disk journal mode without accessing WAL shared memory.
|
||||
/// Use SQLite's descriptor so closing it cannot release another connection's locks.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "NFS inspection needs an immutable connection without shared memory"
|
||||
)]
|
||||
pub async fn needs_migration(source: &Path) -> anyhow::Result<bool> {
|
||||
if !regular_file_exists(source)? {
|
||||
let replacement = Replacement::new(source)?;
|
||||
return Ok(regular_file_exists(&replacement.backup_database)?
|
||||
|| regular_file_exists(&replacement.replacement_path())?);
|
||||
}
|
||||
let mut connection = SqliteConnection::connect_with(
|
||||
&SqliteConnectOptions::new()
|
||||
.filename(source)
|
||||
.read_only(true)
|
||||
.immutable(true)
|
||||
.disable_statement_logging(),
|
||||
)
|
||||
.await?;
|
||||
let inspected: anyhow::Result<bool> = async {
|
||||
let mut handle = connection.lock_handle().await?;
|
||||
let mut file: *mut libsqlite3_sys::sqlite3_file = std::ptr::null_mut();
|
||||
// SAFETY: SQLx's guard excludes its worker. FILE_POINTER writes a borrowed
|
||||
// sqlite3_file pointer, valid while this connection and guard remain alive.
|
||||
let status = unsafe {
|
||||
libsqlite3_sys::sqlite3_file_control(
|
||||
handle.as_raw_handle().as_ptr(),
|
||||
c"main".as_ptr(),
|
||||
libsqlite3_sys::SQLITE_FCNTL_FILE_POINTER,
|
||||
(&raw mut file).cast(),
|
||||
)
|
||||
};
|
||||
ensure!(
|
||||
status == libsqlite3_sys::SQLITE_OK && !file.is_null(),
|
||||
"cannot inspect database header"
|
||||
);
|
||||
// SAFETY: FILE_POINTER returned a live file owned by the guarded connection.
|
||||
let methods = unsafe { (*file).pMethods };
|
||||
ensure!(!methods.is_null(), "database VFS has no file methods");
|
||||
let mut header = [0u8; 20];
|
||||
// SAFETY: the built-in file VFS supplies xRead; the guarded file is live,
|
||||
// and the buffer has space for exactly the requested 20 bytes.
|
||||
let status = unsafe {
|
||||
let read = (*methods).xRead.context("database VFS cannot read")?;
|
||||
read(
|
||||
file,
|
||||
header.as_mut_ptr().cast(),
|
||||
/*iAmt*/ 20,
|
||||
/*iOfst*/ 0,
|
||||
)
|
||||
};
|
||||
ensure!(
|
||||
status == libsqlite3_sys::SQLITE_OK,
|
||||
"cannot read database header: {status}"
|
||||
);
|
||||
ensure!(
|
||||
&header[..16] == b"SQLite format 3\0",
|
||||
"invalid database header"
|
||||
);
|
||||
// SQLite file-format read/write versions are 1 for rollback and 2 for WAL.
|
||||
Ok(header[18] == 2 || header[19] == 2)
|
||||
}
|
||||
.await;
|
||||
let closed = connection.close().await;
|
||||
let needed = inspected?;
|
||||
closed?;
|
||||
Ok(needed)
|
||||
}
|
||||
|
||||
pub fn lock(sqlite_home: &Path) -> anyhow::Result<Self> {
|
||||
ensure!(
|
||||
cfg!(unix),
|
||||
@@ -57,8 +127,34 @@ impl NfsDbMigration {
|
||||
Ok(Self { _lock: lock })
|
||||
}
|
||||
|
||||
/// Whether source or recovery files have permission bits other than 0600.
|
||||
/// Only inspect metadata: opening WAL databases can create shared-memory files.
|
||||
pub fn needs_permission_confirmation(source: &Path) -> anyhow::Result<bool> {
|
||||
let replacement = Replacement::new(source)?;
|
||||
for path in [
|
||||
source.to_path_buf(),
|
||||
replacement.backup_database.clone(),
|
||||
replacement.replacement_path(),
|
||||
] {
|
||||
if !regular_file_exists(&path)? {
|
||||
continue;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if std::fs::metadata(path)?.permissions().mode() & 0o777 != 0o600 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Convert an existing WAL database or resume an interrupted replacement.
|
||||
/// All other clients must remain stopped until this returns successfully.
|
||||
/// Callers must obtain consent or require unsafe migration through managed policy.
|
||||
/// The replacement preserves mode bits, but belongs to the current user and does not
|
||||
/// copy the original owner, group, or extended ACLs.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Migration needs a dedicated exclusive connection, not a runtime pool"
|
||||
@@ -93,7 +189,7 @@ impl NfsDbMigration {
|
||||
// SQLite then locks the database file without shared memory across NFS client kernels.
|
||||
.locking_mode(SqliteLockingMode::Exclusive)
|
||||
.synchronous(SqliteSynchronous::Full)
|
||||
.busy_timeout(Duration::ZERO)
|
||||
.busy_timeout(Duration::from_secs(/*secs*/ 5))
|
||||
.disable_statement_logging();
|
||||
let mut connection = SqliteConnection::connect_with(&options).await.context(
|
||||
"cannot open source database exclusively; close its SQLite clients on all hosts",
|
||||
@@ -150,8 +246,15 @@ impl NfsDbMigration {
|
||||
// A DELETE no-op must not open raw descriptors: closing them could release
|
||||
// POSIX locks held by another pool in this process.
|
||||
source_files.push(File::open(source)?);
|
||||
let source_permissions = source_files[0].metadata()?.permissions();
|
||||
#[cfg(unix)]
|
||||
access::require_private_database(&source_files[0])?;
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
ensure!(
|
||||
source_permissions.mode() & 0o600 == 0o600,
|
||||
"database owner permissions must include read and write so the replacement remains accessible to its new owner"
|
||||
);
|
||||
}
|
||||
// The private directory protects WAL contents even on a shared /tmp.
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("codex-nfs-migration-")
|
||||
@@ -197,9 +300,7 @@ impl NfsDbMigration {
|
||||
// Use the open handle. Closing another source descriptor can release SQLite's locks.
|
||||
temporary
|
||||
.as_file()
|
||||
.set_permissions(source_files[0].metadata()?.permissions())?;
|
||||
#[cfg(unix)]
|
||||
access::require_private_database(temporary.as_file())?;
|
||||
.set_permissions(source_permissions)?;
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary
|
||||
.into_temp_path()
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Reject databases with access permissions that replacement cannot preserve.
|
||||
//! Accept only files owned by the current user with no group or other access.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::ensure;
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
pub(super) fn require_private_database(file: &File) -> anyhow::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
let user = unsafe { libc::geteuid() };
|
||||
ensure!(
|
||||
metadata.uid() == user,
|
||||
"database must be owned by the current user"
|
||||
);
|
||||
ensure!(
|
||||
metadata.mode() & 0o077 == 0,
|
||||
"shared database access is not supported: group and other permissions must be absent"
|
||||
);
|
||||
check_acl(file).context("cannot verify private database access")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn check_acl(file: &File) -> anyhow::Result<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
// POSIX ACL grants are limited by the group mode bits checked above.
|
||||
// NFSv4 named entries can grant access independently of those bits.
|
||||
let mut buffer = vec![0u8; 65536];
|
||||
// SAFETY: The descriptor is open. The name and writable buffer are valid for this call.
|
||||
let size = unsafe {
|
||||
libc::fgetxattr(
|
||||
file.as_raw_fd(),
|
||||
c"system.nfs4_acl".as_ptr(),
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
)
|
||||
};
|
||||
if size < 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return match error.raw_os_error() {
|
||||
Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(()),
|
||||
_ => Err(error.into()),
|
||||
};
|
||||
}
|
||||
let mut bytes = &buffer[..size as usize];
|
||||
let read_word = |bytes: &mut &[u8]| -> anyhow::Result<u32> {
|
||||
let (word, rest) = bytes
|
||||
.split_at_checked(/*mid*/ 4)
|
||||
.context("invalid NFSv4 ACL")?;
|
||||
*bytes = rest;
|
||||
Ok(u32::from_be_bytes(word.try_into()?))
|
||||
};
|
||||
let entries = read_word(&mut bytes)?;
|
||||
for _ in 0..entries {
|
||||
let kind = read_word(&mut bytes)?;
|
||||
let _flags = read_word(&mut bytes)?;
|
||||
let permissions = read_word(&mut bytes)?;
|
||||
let length = read_word(&mut bytes)? as usize;
|
||||
let (name, rest) = bytes
|
||||
.split_at_checked(length)
|
||||
.context("invalid NFSv4 ACL")?;
|
||||
ensure!(
|
||||
matches!(name, b"OWNER@" | b"GROUP@" | b"EVERYONE@"),
|
||||
"databases with named NFSv4 ACL entries are not supported"
|
||||
);
|
||||
ensure!(kind <= 1, "unsupported NFSv4 ACL entry type");
|
||||
// Other users can read attributes and ACLs, and synchronize. Reject all other grants.
|
||||
const READ_ATTRIBUTES: u32 = 0x80;
|
||||
const READ_ACL: u32 = 0x20000;
|
||||
const SYNCHRONIZE: u32 = 0x100000;
|
||||
ensure!(
|
||||
kind != 0
|
||||
|| name == b"OWNER@"
|
||||
|| permissions & !(READ_ATTRIBUTES | READ_ACL | SYNCHRONIZE) == 0,
|
||||
"shared database access through an NFSv4 ACL is not supported"
|
||||
);
|
||||
bytes = rest
|
||||
.get((4 - length % 4) % 4..)
|
||||
.context("invalid NFSv4 ACL padding")?;
|
||||
}
|
||||
ensure!(bytes.is_empty(), "invalid NFSv4 ACL trailing data");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_acl(file: &File) -> anyhow::Result<()> {
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn acl_get_fd(fd: libc::c_int) -> *mut c_void;
|
||||
fn acl_get_entry(
|
||||
acl: *mut c_void,
|
||||
entry_id: libc::c_int,
|
||||
entry: *mut *mut c_void,
|
||||
) -> libc::c_int;
|
||||
fn acl_free(acl: *mut c_void) -> libc::c_int;
|
||||
}
|
||||
// SAFETY: The descriptor is open. The returned ACL remains valid until acl_free.
|
||||
let acl = unsafe { acl_get_fd(file.as_raw_fd()) };
|
||||
if acl.is_null() {
|
||||
let error = std::io::Error::last_os_error();
|
||||
return match error.raw_os_error() {
|
||||
// ENOENT means the open file has no extended ACL.
|
||||
Some(libc::ENOENT | libc::ENOTSUP) => Ok(()),
|
||||
_ => Err(error.into()),
|
||||
};
|
||||
}
|
||||
let mut entry = std::ptr::null_mut();
|
||||
// SAFETY: The ACL and output pointer are valid. Zero selects ACL_FIRST_ENTRY on macOS.
|
||||
let status = unsafe {
|
||||
acl_get_entry(acl, /*entry_id*/ 0, &mut entry)
|
||||
};
|
||||
let error = std::io::Error::last_os_error();
|
||||
// SAFETY: This call releases the ACL returned by acl_get_fd exactly once.
|
||||
unsafe { acl_free(acl) };
|
||||
ensure!(
|
||||
status != 0,
|
||||
"databases with extended macOS ACLs are not supported"
|
||||
);
|
||||
ensure!(
|
||||
error.raw_os_error() == Some(libc::EINVAL),
|
||||
"cannot read database ACL: {error}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn check_acl(_file: &File) -> anyhow::Result<()> {
|
||||
anyhow::bail!("database ACL checks are not supported on this operating system")
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub(super) enum Recovery {
|
||||
pub(super) struct Replacement {
|
||||
source: PathBuf,
|
||||
pub(super) backup_directory: PathBuf,
|
||||
backup_database: PathBuf,
|
||||
pub(super) backup_database: PathBuf,
|
||||
}
|
||||
|
||||
impl Replacement {
|
||||
@@ -156,14 +156,6 @@ impl Replacement {
|
||||
let backup_exists = regular_file_exists(&self.backup_database)?;
|
||||
let replacement_exists = regular_file_exists(&self.replacement_path())?;
|
||||
let source_exists = regular_file_exists(&self.source)?;
|
||||
#[cfg(unix)]
|
||||
if backup_exists {
|
||||
super::access::require_private_database(&File::open(&self.backup_database)?)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if replacement_exists {
|
||||
super::access::require_private_database(&File::open(self.replacement_path())?)?;
|
||||
}
|
||||
// No original files moved yet. The caller must lock the source and rebuild the copy.
|
||||
if !backup_exists {
|
||||
for suffix in SIDECARS {
|
||||
|
||||
@@ -219,6 +219,7 @@ impl SqliteConfig {
|
||||
}
|
||||
|
||||
/// When enforcement is enabled, migrate existing WAL databases on NFS before opening them.
|
||||
/// Other clients on all hosts must remain stopped during migration.
|
||||
pub fn enable_delete_mode_on_nfs(mut self, enabled: bool) -> Self {
|
||||
self.enforce_delete_on_nfs = enabled;
|
||||
self
|
||||
|
||||
@@ -452,13 +452,24 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart
|
||||
.await?
|
||||
.close()
|
||||
.await;
|
||||
let state_path = original.state_db_path();
|
||||
assert!(crate::NfsDbMigration::needs_migration(&state_path).await?);
|
||||
let bytes = std::fs::read(&state_path)?;
|
||||
std::fs::set_permissions(&state_path, std::fs::Permissions::from_mode(/*mode*/ 0o060))?;
|
||||
let staging = tempfile::tempdir()?;
|
||||
let result = crate::NfsDbMigration::lock(home.path())?
|
||||
.migrate(&state_path, staging.path())
|
||||
.await;
|
||||
std::fs::set_permissions(&state_path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(std::fs::read(&state_path)?, bytes);
|
||||
let mut backups = Vec::new();
|
||||
for db in original
|
||||
.runtime_db_paths()
|
||||
.into_iter()
|
||||
.filter(|db| db.path.exists())
|
||||
{
|
||||
std::fs::set_permissions(&db.path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?;
|
||||
std::fs::set_permissions(&db.path, std::fs::Permissions::from_mode(/*mode*/ 0o644))?;
|
||||
let name = db.path.file_name().unwrap().to_str().unwrap();
|
||||
backups.push((
|
||||
home.path()
|
||||
@@ -472,11 +483,15 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart
|
||||
/*enforce_delete_on_nfs*/ true,
|
||||
);
|
||||
sqlite.is_nfs.set(/*value*/ true)?;
|
||||
assert!(crate::NfsDbMigration::needs_permission_confirmation(
|
||||
&sqlite.state_db_path()
|
||||
)?);
|
||||
let migrated = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?;
|
||||
assert_eq!(migrated.get_backfill_state().await?, before);
|
||||
migrated.checkpoint_backfill("after-migration").await?;
|
||||
let after = migrated.get_backfill_state().await?;
|
||||
migrated.close().await;
|
||||
assert!(!crate::NfsDbMigration::needs_migration(&state_path).await?);
|
||||
|
||||
// Simulate a crash after moving the original but before reinstalling a lazy database.
|
||||
let lazy = sqlite.memories_v2_db_path();
|
||||
@@ -486,6 +501,8 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart
|
||||
home.path()
|
||||
.join(format!("{name}_wal_migration_backup/delete.sqlite")),
|
||||
)?;
|
||||
assert!(crate::NfsDbMigration::needs_permission_confirmation(&lazy)?);
|
||||
assert!(crate::NfsDbMigration::needs_migration(&lazy).await?);
|
||||
let restarted = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?;
|
||||
assert_eq!(restarted.get_backfill_state().await?, after);
|
||||
restarted.close().await;
|
||||
@@ -499,6 +516,15 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart
|
||||
.await?;
|
||||
assert_eq!(settings, ("delete".into(), 3));
|
||||
pool.close().await;
|
||||
if backups
|
||||
.iter()
|
||||
.any(|(backup, _)| backup.file_name() == db.path.file_name())
|
||||
{
|
||||
assert_eq!(
|
||||
std::fs::metadata(&db.path)?.permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
}
|
||||
for (path, bytes) in backups {
|
||||
assert_eq!(std::fs::read(path)?, bytes);
|
||||
@@ -532,6 +558,7 @@ async fn required_nfs_migration_refuses_active_wal_and_retries_after_close() ->
|
||||
.enable_delete_mode_on_nfs(/*enabled*/ true);
|
||||
required.is_nfs.set(/*value*/ true)?;
|
||||
required.logs_is_nfs.set(/*value*/ false)?;
|
||||
assert!(crate::NfsDbMigration::needs_migration(&path).await?);
|
||||
let error = required.open_read_write_pool(&path).await.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app/startup_warnings_tests.rs
|
||||
expression: text
|
||||
---
|
||||
⚠ Some Codex SQLite files on NFS have permissions other than 0600. Migration
|
||||
preserves these permissions and requires your confirmation. Before running
|
||||
`codex migrate-nfs-db`, stop Codex on every host that uses these databases.
|
||||
@@ -944,6 +944,14 @@ impl App {
|
||||
history_cell::StartupWarningsCell::new(vec![warning]),
|
||||
)));
|
||||
}
|
||||
// Automatic NFS detection is Linux-only; manual migration supports Unix.
|
||||
#[cfg(target_os = "linux")]
|
||||
if !app_server_target.uses_remote_workspace() {
|
||||
tokio::spawn(startup_warnings::warn_about_nfs_migration(
|
||||
config.sqlite_config().clone(),
|
||||
app_event_tx.clone(),
|
||||
));
|
||||
}
|
||||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||||
let runtime_keymap =
|
||||
RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Coalesce retained startup diagnostics without adding live transcript rows or forcing reflow.
|
||||
//! Detect Linux NFS migration permission warnings and coalesce retained startup diagnostics.
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::StartupWarningsCell;
|
||||
@@ -62,3 +62,39 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const NFS_MIGRATION_WARNING: &str = "Some Codex SQLite files on NFS have permissions other than 0600. Migration preserves these permissions and requires your confirmation. Before running `codex migrate-nfs-db`, stop Codex on every host that uses these databases.";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) async fn warn_about_nfs_migration(
|
||||
sqlite: codex_state::SqliteConfig,
|
||||
app_event_tx: AppEventSender,
|
||||
) {
|
||||
let inspection: anyhow::Result<bool> = async {
|
||||
if !sqlite.is_nfs().await? {
|
||||
return Ok(false);
|
||||
}
|
||||
for database in sqlite.runtime_db_paths() {
|
||||
if database.path.parent() == Some(sqlite.home())
|
||||
&& codex_state::NfsDbMigration::needs_permission_confirmation(&database.path)?
|
||||
&& codex_state::NfsDbMigration::needs_migration(&database.path).await?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
.await;
|
||||
match inspection {
|
||||
Ok(true) => app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
StartupWarningsCell::new(vec![NFS_MIGRATION_WARNING.to_string()]),
|
||||
))),
|
||||
Ok(false) => {}
|
||||
Err(error) => tracing::debug!(%error, "Could not inspect NFS database permissions"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
#[path = "startup_warnings_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
#[test]
|
||||
fn nfs_migration_warning() {
|
||||
let cell = StartupWarningsCell::new(vec![NFS_MIGRATION_WARNING.to_string()]);
|
||||
let lines = cell.transcript_lines(/*width*/ 80);
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(text);
|
||||
}
|
||||
Reference in New Issue
Block a user