mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-24 13:16:28 +00:00
fix(connect): make registration bootstrap retry durable (#6468)
* fix(connect): make registration state durability retry-safe * fix(connect): reject parent state paths * fix(connect): harden state directory creation * fix(connect): bound bootstrap directory syncs * fix(connect): require durable state parent * fix(connect): close bootstrap marker race * test(connect): cover marker sync failure
This commit is contained in:
@@ -17,7 +17,9 @@ use std::path::Path;
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, File, OpenOptions},
|
fs::{self, File, OpenOptions},
|
||||||
path::PathBuf,
|
io::Write as _,
|
||||||
|
path::{Component, PathBuf},
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +29,12 @@ use super::{ConnectClient, ConnectConfig, CredentialStore, IdentityStore, Regist
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
#[cfg(unix)]
|
||||||
|
const BOOTSTRAP_READY_FILE: &str = ".bootstrap-ready";
|
||||||
|
#[cfg(unix)]
|
||||||
|
const BOOTSTRAP_READY_CONTENTS: &[u8] = b"v1\n";
|
||||||
|
#[cfg(unix)]
|
||||||
|
static BOOTSTRAP_STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct RegistrationBootstrapResult {
|
pub struct RegistrationBootstrapResult {
|
||||||
@@ -42,6 +50,10 @@ pub enum RegistrationBootstrapError {
|
|||||||
RootCaFileSecurity,
|
RootCaFileSecurity,
|
||||||
#[error("the Connect state path must be an explicit directory, not a symlink")]
|
#[error("the Connect state path must be an explicit directory, not a symlink")]
|
||||||
StateDirectorySecurity,
|
StateDirectorySecurity,
|
||||||
|
#[error("the Connect state directory parent must be pre-provisioned as a secure durable directory")]
|
||||||
|
StateParentRequired,
|
||||||
|
#[error("the Connect bootstrap readiness marker must be an owner-only regular file")]
|
||||||
|
StateMarkerSecurity,
|
||||||
#[error("failed to read protected Connect registration input")]
|
#[error("failed to read protected Connect registration input")]
|
||||||
Input(#[source] io::Error),
|
Input(#[source] io::Error),
|
||||||
#[error("Connect registration configuration is invalid")]
|
#[error("Connect registration configuration is invalid")]
|
||||||
@@ -112,62 +124,207 @@ fn prepare_state_directory(path: &Path) -> Result<PathBuf, RegistrationBootstrap
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn prepare_state_directory_with_sync(
|
fn prepare_state_directory_with_sync(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
mut sync: impl FnMut(&Path) -> io::Result<()>,
|
sync: impl FnMut(&Path) -> io::Result<()>,
|
||||||
) -> Result<PathBuf, RegistrationBootstrapError> {
|
) -> Result<PathBuf, RegistrationBootstrapError> {
|
||||||
|
prepare_state_directory_with_sync_and_missing_observer(path, sync, |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn prepare_state_directory_with_sync_and_missing_observer(
|
||||||
|
path: &Path,
|
||||||
|
mut sync: impl FnMut(&Path) -> io::Result<()>,
|
||||||
|
mut observed_missing: impl FnMut(&Path),
|
||||||
|
) -> Result<PathBuf, RegistrationBootstrapError> {
|
||||||
|
if path.components().any(|component| matches!(component, Component::ParentDir)) {
|
||||||
|
return Err(RegistrationBootstrapError::StateDirectorySecurity);
|
||||||
|
}
|
||||||
let path = if path.is_absolute() {
|
let path = if path.is_absolute() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
std::env::current_dir().map_err(RegistrationBootstrapError::Input)?.join(path)
|
std::env::current_dir().map_err(RegistrationBootstrapError::Input)?.join(path)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut directories = path.ancestors().map(Path::to_path_buf).collect::<Vec<_>>();
|
let state_parent = path.parent().ok_or(RegistrationBootstrapError::StateParentRequired)?;
|
||||||
|
match fs::symlink_metadata(state_parent) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return Err(RegistrationBootstrapError::StateParentRequired);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(RegistrationBootstrapError::Input(error)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut directories = state_parent.ancestors().map(Path::to_path_buf).collect::<Vec<_>>();
|
||||||
directories.reverse();
|
directories.reverse();
|
||||||
for directory in &directories {
|
for directory in &directories {
|
||||||
if ensure_directory(directory, directory == &path)? {
|
validate_directory(directory, false)?;
|
||||||
sync(directory).map_err(RegistrationBootstrapError::Input)?;
|
|
||||||
let parent = directory.parent().ok_or_else(|| {
|
|
||||||
RegistrationBootstrapError::Input(io::Error::new(io::ErrorKind::InvalidInput, "directory has no parent"))
|
|
||||||
})?;
|
|
||||||
sync(parent).map_err(RegistrationBootstrapError::Input)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let store_directories = [path.join("identity"), path.join("credential")];
|
|
||||||
for directory in &store_directories {
|
let state_exists = match fs::symlink_metadata(&path) {
|
||||||
if ensure_directory(directory, true)? {
|
Ok(_) => {
|
||||||
sync(directory).map_err(RegistrationBootstrapError::Input)?;
|
validate_directory(&path, true)?;
|
||||||
let parent = directory.parent().ok_or_else(|| {
|
true
|
||||||
RegistrationBootstrapError::Input(io::Error::new(io::ErrorKind::InvalidInput, "directory has no parent"))
|
|
||||||
})?;
|
|
||||||
sync(parent).map_err(RegistrationBootstrapError::Input)?;
|
|
||||||
}
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
|
||||||
|
Err(error) => return Err(RegistrationBootstrapError::Input(error)),
|
||||||
|
};
|
||||||
|
let store_directories = [path.join("identity"), path.join("credential")];
|
||||||
|
let ready = path.join(BOOTSTRAP_READY_FILE);
|
||||||
|
if state_exists && ready_marker_exists(&ready)? {
|
||||||
|
for directory in &store_directories {
|
||||||
|
validate_directory(directory, true)?;
|
||||||
|
}
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_directory(&path, true, &mut observed_missing)?;
|
||||||
|
for directory in &store_directories {
|
||||||
|
ensure_directory(directory, true, &mut observed_missing)?;
|
||||||
}
|
}
|
||||||
for directory in &directories {
|
for directory in &directories {
|
||||||
validate_directory(directory, directory == &path)?;
|
validate_directory(directory, false)?;
|
||||||
}
|
}
|
||||||
|
validate_directory(&path, true)?;
|
||||||
for directory in &store_directories {
|
for directory in &store_directories {
|
||||||
validate_directory(directory, true)?;
|
validate_directory(directory, true)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The caller-provisioned parent is the durability anchor. Without a ready
|
||||||
|
// marker, repeat this complete commit sequence after every interruption.
|
||||||
|
for directory in [
|
||||||
|
store_directories[0].clone(),
|
||||||
|
store_directories[1].clone(),
|
||||||
|
path.clone(),
|
||||||
|
state_parent.to_path_buf(),
|
||||||
|
] {
|
||||||
|
sync(&directory).map_err(RegistrationBootstrapError::Input)?;
|
||||||
|
}
|
||||||
|
publish_ready_marker(&path, &ready)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn ensure_directory(path: &Path, require_process_owner: bool) -> Result<bool, RegistrationBootstrapError> {
|
fn ready_marker_exists(path: &Path) -> Result<bool, RegistrationBootstrapError> {
|
||||||
|
let initial = match fs::symlink_metadata(path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(error) => return Err(RegistrationBootstrapError::Input(error)),
|
||||||
|
};
|
||||||
|
if initial.file_type().is_symlink() || !initial.is_file() {
|
||||||
|
return Err(RegistrationBootstrapError::StateMarkerSecurity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.read(true);
|
||||||
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
|
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||||
|
let mut file = options.open(path).map_err(RegistrationBootstrapError::Input)?;
|
||||||
|
let metadata = file.metadata().map_err(RegistrationBootstrapError::Input)?;
|
||||||
|
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||||
|
let mode = metadata.permissions().mode() & 0o777;
|
||||||
|
if !metadata.is_file() || !unix_ready_marker_is_trusted(metadata.uid(), mode, process_uid()) {
|
||||||
|
return Err(RegistrationBootstrapError::StateMarkerSecurity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut contents = Vec::with_capacity(BOOTSTRAP_READY_CONTENTS.len() + 1);
|
||||||
|
io::Read::read_to_end(&mut io::Read::take(&mut file, (BOOTSTRAP_READY_CONTENTS.len() + 1) as u64), &mut contents)
|
||||||
|
.map_err(RegistrationBootstrapError::Input)?;
|
||||||
|
if contents != BOOTSTRAP_READY_CONTENTS {
|
||||||
|
return Err(RegistrationBootstrapError::StateMarkerSecurity);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn publish_ready_marker(state_directory: &Path, ready: &Path) -> Result<(), RegistrationBootstrapError> {
|
||||||
|
publish_ready_marker_with_existing_observer(state_directory, ready, File::sync_all, || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn publish_ready_marker_with_existing_observer(
|
||||||
|
state_directory: &Path,
|
||||||
|
ready: &Path,
|
||||||
|
mut sync_staging: impl FnMut(&File) -> io::Result<()>,
|
||||||
|
mut existing_observer: impl FnMut(),
|
||||||
|
) -> Result<(), RegistrationBootstrapError> {
|
||||||
|
let (staging_path, mut staging) = loop {
|
||||||
|
let staging_path = state_directory.join(format!(
|
||||||
|
"{BOOTSTRAP_READY_FILE}.{}.{}.tmp",
|
||||||
|
std::process::id(),
|
||||||
|
BOOTSTRAP_STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
|
options.mode(0o600);
|
||||||
|
match options.open(&staging_path) {
|
||||||
|
Ok(file) => break (staging_path, file),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(RegistrationBootstrapError::Input(error)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let staged = (|| -> io::Result<()> {
|
||||||
|
staging.write_all(BOOTSTRAP_READY_CONTENTS)?;
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
staging.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||||
|
sync_staging(&staging)
|
||||||
|
})();
|
||||||
|
drop(staging);
|
||||||
|
if let Err(error) = staged {
|
||||||
|
let _ = fs::remove_file(&staging_path);
|
||||||
|
return Err(RegistrationBootstrapError::Input(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-link publication is atomic and, unlike rename, cannot replace a
|
||||||
|
// marker planted between validation and publication.
|
||||||
|
let published = fs::hard_link(&staging_path, ready);
|
||||||
|
if let Err(error) = fs::remove_file(&staging_path) {
|
||||||
|
if published.is_ok() {
|
||||||
|
let _ = fs::remove_file(ready);
|
||||||
|
}
|
||||||
|
return Err(RegistrationBootstrapError::Input(error));
|
||||||
|
}
|
||||||
|
match published {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
|
existing_observer();
|
||||||
|
if ready_marker_exists(ready)? {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(RegistrationBootstrapError::Input(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"bootstrap readiness marker disappeared during publication",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err(RegistrationBootstrapError::Input(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn ensure_directory(
|
||||||
|
path: &Path,
|
||||||
|
require_process_owner: bool,
|
||||||
|
observed_missing: &mut impl FnMut(&Path),
|
||||||
|
) -> Result<(), RegistrationBootstrapError> {
|
||||||
match fs::symlink_metadata(path) {
|
match fs::symlink_metadata(path) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
validate_directory(path, require_process_owner)?;
|
validate_directory(path, require_process_owner)?;
|
||||||
Ok(false)
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
|
observed_missing(path);
|
||||||
let mut builder = fs::DirBuilder::new();
|
let mut builder = fs::DirBuilder::new();
|
||||||
use std::os::unix::fs::DirBuilderExt as _;
|
use std::os::unix::fs::DirBuilderExt as _;
|
||||||
builder.mode(0o700);
|
builder.mode(0o700);
|
||||||
match builder.create(path) {
|
match builder.create(path) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
validate_directory(path, true)?;
|
validate_directory(path, true)?;
|
||||||
Ok(true)
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
Err(RegistrationBootstrapError::StateDirectorySecurity)
|
validate_directory(path, require_process_owner)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) => Err(RegistrationBootstrapError::Input(error)),
|
Err(error) => Err(RegistrationBootstrapError::Input(error)),
|
||||||
}
|
}
|
||||||
@@ -201,6 +358,11 @@ fn unix_directory_is_trusted(owner_uid: u32, mode: u32, process_uid: u32, requir
|
|||||||
(owner_uid == process_uid || (!require_process_owner && owner_uid == 0)) && mode & 0o022 == 0
|
(owner_uid == process_uid || (!require_process_owner && owner_uid == 0)) && mode & 0o022 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn unix_ready_marker_is_trusted(owner_uid: u32, mode: u32, process_uid: u32) -> bool {
|
||||||
|
owner_uid == process_uid && mode & 0o400 != 0 && mode & 0o177 == 0
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
// SAFETY: geteuid has no pointer arguments or caller preconditions.
|
// SAFETY: geteuid has no pointer arguments or caller preconditions.
|
||||||
#[allow(unsafe_code)]
|
#[allow(unsafe_code)]
|
||||||
@@ -259,14 +421,32 @@ fn unix_ca_file_is_trusted(owner_uid: u32, mode: u32, process_uid: u32) -> bool
|
|||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::fs;
|
use std::fs::{self, File};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::{PermissionsExt as _, symlink};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
RegistrationBootstrapError, prepare_state_directory_with_sync, unix_ca_file_is_trusted, unix_directory_is_trusted,
|
BOOTSTRAP_READY_CONTENTS, BOOTSTRAP_READY_FILE, RegistrationBootstrapError, prepare_state_directory_with_sync,
|
||||||
|
prepare_state_directory_with_sync_and_missing_observer, publish_ready_marker_with_existing_observer, ready_marker_exists,
|
||||||
|
sync_directory, unix_ca_file_is_trusted, unix_directory_is_trusted, unix_ready_marker_is_trusted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn secure_tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix(".connect-registration-bootstrap-")
|
||||||
|
.tempdir_in(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.expect("temporary directory inside the protected checkout")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_secure_state_tree(state: &Path) {
|
||||||
|
for directory in [state.to_path_buf(), state.join("identity"), state.join("credential")] {
|
||||||
|
fs::create_dir_all(&directory).expect("create state directory");
|
||||||
|
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).expect("secure state directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unix_directory_policy_rejects_wrong_owners_and_writable_modes_only() {
|
fn unix_directory_policy_rejects_wrong_owners_and_writable_modes_only() {
|
||||||
let process_uid = 501;
|
let process_uid = 501;
|
||||||
@@ -292,60 +472,385 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_state_chain_syncs_each_created_directory_then_parent_and_propagates_failure() {
|
fn unix_ready_marker_policy_requires_process_owner_and_owner_only_readability() {
|
||||||
let temp = tempfile::tempdir().expect("temporary directory");
|
let process_uid = 501;
|
||||||
let ancestor = temp.path().join("connect");
|
|
||||||
let state = ancestor.join("state");
|
|
||||||
let observed = RefCell::new(Vec::new());
|
|
||||||
let calls = Cell::new(0);
|
|
||||||
|
|
||||||
let error = prepare_state_directory_with_sync(&state, |path| {
|
assert!(unix_ready_marker_is_trusted(process_uid, 0o400, process_uid));
|
||||||
|
assert!(unix_ready_marker_is_trusted(process_uid, 0o600, process_uid));
|
||||||
|
assert!(!unix_ready_marker_is_trusted(process_uid + 1, 0o600, process_uid));
|
||||||
|
assert!(!unix_ready_marker_is_trusted(process_uid, 0o200, process_uid));
|
||||||
|
assert!(!unix_ready_marker_is_trusted(process_uid, 0o640, process_uid));
|
||||||
|
assert!(!unix_ready_marker_is_trusted(process_uid, 0o602, process_uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_state_syncs_managed_directories_and_preprovisioned_parent() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let state = temp.path().join("state");
|
||||||
|
let observed = RefCell::new(Vec::new());
|
||||||
|
|
||||||
|
let prepared = prepare_state_directory_with_sync(&state, |path| {
|
||||||
assert!(path.is_dir(), "directory must exist before it is synced");
|
assert!(path.is_dir(), "directory must exist before it is synced");
|
||||||
observed.borrow_mut().push(path.to_path_buf());
|
observed.borrow_mut().push(path.to_path_buf());
|
||||||
calls.set(calls.get() + 1);
|
|
||||||
if calls.get() == 8 {
|
|
||||||
return Err(io::Error::other("injected final parent sync failure"));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.expect_err("parent sync failure must stop bootstrap preparation");
|
.expect("new state tree must become ready");
|
||||||
|
|
||||||
assert!(matches!(error, RegistrationBootstrapError::Input(_)));
|
assert_eq!(prepared, state);
|
||||||
|
let observed = observed.into_inner();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed.into_inner(),
|
observed,
|
||||||
vec![
|
[
|
||||||
ancestor.clone(),
|
|
||||||
temp.path().to_path_buf(),
|
|
||||||
state.clone(),
|
|
||||||
ancestor,
|
|
||||||
state.join("identity"),
|
state.join("identity"),
|
||||||
state.clone(),
|
|
||||||
state.join("credential"),
|
state.join("credential"),
|
||||||
state,
|
state,
|
||||||
]
|
temp.path().to_path_buf(),
|
||||||
|
],
|
||||||
|
"new state trees must sync only managed directories and their durable parent"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn existing_state_tree_is_validated_without_syncing() {
|
fn missing_state_parent_fails_before_creation_or_sync() {
|
||||||
let temp = tempfile::tempdir().expect("temporary directory");
|
let temp = secure_tempdir();
|
||||||
let state = temp.path().join("state");
|
let missing_parent = temp.path().join("missing");
|
||||||
let directories = [state.clone(), state.join("identity"), state.join("credential")];
|
let state = missing_parent.join("state");
|
||||||
for directory in &directories {
|
|
||||||
fs::create_dir_all(directory).expect("create existing state directory");
|
|
||||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("secure existing state directory");
|
|
||||||
}
|
|
||||||
let calls = Cell::new(0);
|
let calls = Cell::new(0);
|
||||||
|
|
||||||
let prepared = prepare_state_directory_with_sync(&state, |_| {
|
let error = prepare_state_directory_with_sync(&state, |_| {
|
||||||
calls.set(calls.get() + 1);
|
calls.set(calls.get() + 1);
|
||||||
Err(io::Error::other("existing directories must not be synced"))
|
Ok(())
|
||||||
})
|
})
|
||||||
.expect("existing secure state tree should be ready");
|
.expect_err("state parent must be provisioned before bootstrap");
|
||||||
|
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateParentRequired));
|
||||||
|
assert_eq!(calls.get(), 0);
|
||||||
|
assert!(!missing_parent.exists());
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"the Connect state directory parent must be pre-provisioned as a secure durable directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_components_fail_before_creation_or_sync_while_current_directory_is_allowed() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let absolute_parent = temp.path().join("secure/connect/..");
|
||||||
|
|
||||||
|
for path in [absolute_parent.as_path(), Path::new("state/../other")] {
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let error = prepare_state_directory_with_sync(path, |_| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect_err("parent components must fail before filesystem preparation");
|
||||||
|
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
assert_eq!(calls.get(), 0);
|
||||||
|
}
|
||||||
|
assert!(!temp.path().join("secure").exists());
|
||||||
|
|
||||||
|
let dotted_state = temp.path().join("./state");
|
||||||
|
prepare_state_directory_with_sync(&dotted_state, |_| Ok(())).expect("current-directory component is safe");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(temp.path().join("state").join(BOOTSTRAP_READY_FILE)).expect("read ready marker"),
|
||||||
|
BOOTSTRAP_READY_CONTENTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_marker_retries_all_managed_syncs_after_final_parent_failure() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let state = temp.path().join("state");
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
let ready = state.join(BOOTSTRAP_READY_FILE);
|
||||||
|
let first_attempt = RefCell::new(Vec::new());
|
||||||
|
|
||||||
|
let error = prepare_state_directory_with_sync(&state, |path| {
|
||||||
|
first_attempt.borrow_mut().push(path.to_path_buf());
|
||||||
|
if path == temp.path() {
|
||||||
|
return Err(io::Error::other("injected final parent sync failure"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect_err("final parent sync failure must stop preparation");
|
||||||
|
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::Input(_)));
|
||||||
|
assert_eq!(
|
||||||
|
first_attempt.into_inner(),
|
||||||
|
vec![
|
||||||
|
state.join("identity"),
|
||||||
|
state.join("credential"),
|
||||||
|
state.clone(),
|
||||||
|
temp.path().to_path_buf()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!ready.exists(), "failed durability preparation must not publish ready");
|
||||||
|
|
||||||
|
let retry = RefCell::new(Vec::new());
|
||||||
|
let prepared = prepare_state_directory_with_sync(&state, |path| {
|
||||||
|
retry.borrow_mut().push(path.to_path_buf());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("retry must repeat durability preparation");
|
||||||
|
|
||||||
|
assert_eq!(prepared, state);
|
||||||
|
assert_eq!(
|
||||||
|
retry.into_inner(),
|
||||||
|
[
|
||||||
|
state.join("identity"),
|
||||||
|
state.join("credential"),
|
||||||
|
state.clone(),
|
||||||
|
temp.path().to_path_buf(),
|
||||||
|
],
|
||||||
|
"an interrupted complete tree must resync only its managed path and direct parent"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&ready).expect("read ready marker"), BOOTSTRAP_READY_CONTENTS);
|
||||||
|
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let prepared = prepare_state_directory_with_sync(&state, |_| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
Err(io::Error::other("ready trees must not sync"))
|
||||||
|
})
|
||||||
|
.expect("safe ready tree should bypass sync");
|
||||||
assert_eq!(prepared, state);
|
assert_eq!(prepared, state);
|
||||||
assert_eq!(calls.get(), 0);
|
assert_eq!(calls.get(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_pre_marker_sync_failure_retries_the_complete_commit() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
|
||||||
|
for fail_at in 1..=4 {
|
||||||
|
let state = temp.path().join(format!("state-{fail_at}"));
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
let expected = [
|
||||||
|
state.join("identity"),
|
||||||
|
state.join("credential"),
|
||||||
|
state.clone(),
|
||||||
|
temp.path().to_path_buf(),
|
||||||
|
];
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let error = prepare_state_directory_with_sync(&state, |path| {
|
||||||
|
assert_eq!(path, &expected[calls.get()]);
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
if calls.get() == fail_at {
|
||||||
|
return Err(io::Error::other("injected pre-marker sync failure"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect_err("every managed durability failure must stop preparation");
|
||||||
|
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::Input(_)));
|
||||||
|
assert_eq!(calls.get(), fail_at);
|
||||||
|
assert!(!state.join(BOOTSTRAP_READY_FILE).exists());
|
||||||
|
|
||||||
|
let retry = RefCell::new(Vec::new());
|
||||||
|
prepare_state_directory_with_sync(&state, |path| {
|
||||||
|
retry.borrow_mut().push(path.to_path_buf());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.expect("retry must repeat the complete durability commit");
|
||||||
|
assert_eq!(retry.into_inner(), expected);
|
||||||
|
assert!(ready_marker_exists(&state.join(BOOTSTRAP_READY_FILE)).expect("validate ready marker"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_directory_replacement_is_revalidated_after_create_race() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
|
||||||
|
let target = temp.path().join("target");
|
||||||
|
fs::create_dir(&target).expect("create symlink target");
|
||||||
|
let linked_state = temp.path().join("linked-state");
|
||||||
|
let error = prepare_state_directory_with_sync_and_missing_observer(
|
||||||
|
&linked_state,
|
||||||
|
|_| panic!("a raced symlink must fail before syncing"),
|
||||||
|
|missing| {
|
||||||
|
if missing == linked_state {
|
||||||
|
symlink(&target, missing).expect("replace missing state with symlink");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("raced symlink must fail closed");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
assert!(!target.join("identity").exists());
|
||||||
|
assert!(!target.join("credential").exists());
|
||||||
|
assert!(!target.join(BOOTSTRAP_READY_FILE).exists());
|
||||||
|
|
||||||
|
let shared_state = temp.path().join("shared-state-race");
|
||||||
|
let error = prepare_state_directory_with_sync_and_missing_observer(
|
||||||
|
&shared_state,
|
||||||
|
|_| panic!("a raced shared directory must fail before syncing"),
|
||||||
|
|missing| {
|
||||||
|
if missing == shared_state {
|
||||||
|
fs::create_dir(missing).expect("replace missing state with directory");
|
||||||
|
fs::set_permissions(missing, fs::Permissions::from_mode(0o770)).expect("share raced directory");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("raced shared directory must fail closed");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
assert!(!shared_state.join("identity").exists());
|
||||||
|
assert!(!shared_state.join("credential").exists());
|
||||||
|
assert!(!shared_state.join(BOOTSTRAP_READY_FILE).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_marker_rejects_symlinks_shared_modes_non_files_and_invalid_contents() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
|
||||||
|
for case in ["symlink", "shared", "directory", "contents"] {
|
||||||
|
let state = temp.path().join(case);
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
let ready = state.join(BOOTSTRAP_READY_FILE);
|
||||||
|
match case {
|
||||||
|
"symlink" => {
|
||||||
|
let target = temp.path().join("marker-target");
|
||||||
|
fs::write(&target, BOOTSTRAP_READY_CONTENTS).expect("write marker target");
|
||||||
|
fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("secure marker target");
|
||||||
|
symlink(target, &ready).expect("create marker symlink");
|
||||||
|
}
|
||||||
|
"shared" => {
|
||||||
|
fs::write(&ready, BOOTSTRAP_READY_CONTENTS).expect("write shared marker");
|
||||||
|
fs::set_permissions(&ready, fs::Permissions::from_mode(0o640)).expect("share marker");
|
||||||
|
}
|
||||||
|
"directory" => fs::create_dir(&ready).expect("create marker directory"),
|
||||||
|
"contents" => {
|
||||||
|
fs::write(&ready, b"not-ready\n").expect("write invalid marker");
|
||||||
|
fs::set_permissions(&ready, fs::Permissions::from_mode(0o600)).expect("secure invalid marker");
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = prepare_state_directory_with_sync(&state, |_| panic!("an unsafe marker must fail before syncing"))
|
||||||
|
.expect_err("unsafe marker must fail closed");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateMarkerSecurity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_removed_after_publication_conflict_fails_closed() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let state = temp.path().join("state");
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
let ready = state.join(BOOTSTRAP_READY_FILE);
|
||||||
|
fs::write(&ready, BOOTSTRAP_READY_CONTENTS).expect("write existing ready marker");
|
||||||
|
fs::set_permissions(&ready, fs::Permissions::from_mode(0o600)).expect("secure existing ready marker");
|
||||||
|
|
||||||
|
let error = publish_ready_marker_with_existing_observer(&state, &ready, File::sync_all, || {
|
||||||
|
fs::remove_file(&ready).expect("remove marker after no-replace conflict");
|
||||||
|
})
|
||||||
|
.expect_err("a marker removed before validation must fail closed");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
RegistrationBootstrapError::Input(ref source) if source.kind() == io::ErrorKind::NotFound
|
||||||
|
));
|
||||||
|
assert!(!ready.exists());
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(&state)
|
||||||
|
.expect("read state directory")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.all(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp")),
|
||||||
|
"failed publication must not leave staging files"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staging_sync_failure_does_not_publish_ready_marker_and_can_retry() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let state = temp.path().join("state");
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
let ready = state.join(BOOTSTRAP_READY_FILE);
|
||||||
|
|
||||||
|
let error = publish_ready_marker_with_existing_observer(
|
||||||
|
&state,
|
||||||
|
&ready,
|
||||||
|
|_| Err(io::Error::other("injected staging sync failure")),
|
||||||
|
|| {},
|
||||||
|
)
|
||||||
|
.expect_err("staging sync failure must stop publication");
|
||||||
|
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::Input(ref source) if source.kind() == io::ErrorKind::Other));
|
||||||
|
assert!(!ready.exists(), "staging sync failure must not publish the marker");
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(&state)
|
||||||
|
.expect("read state directory")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.all(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp")),
|
||||||
|
"staging sync failure must not leave staging files"
|
||||||
|
);
|
||||||
|
|
||||||
|
publish_ready_marker_with_existing_observer(&state, &ready, File::sync_all, || {})
|
||||||
|
.expect("retry should publish a durable marker");
|
||||||
|
assert!(ready_marker_exists(&ready).expect("validate retried ready marker"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_marker_does_not_bypass_ancestor_validation() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let ancestor = temp.path().join("ancestor");
|
||||||
|
let state = ancestor.join("state");
|
||||||
|
create_secure_state_tree(&state);
|
||||||
|
prepare_state_directory_with_sync(&state, |_| Ok(())).expect("publish ready marker");
|
||||||
|
|
||||||
|
fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o770)).expect("share state ancestor");
|
||||||
|
let error = prepare_state_directory_with_sync(&state, |_| panic!("unsafe ancestor must fail before syncing"))
|
||||||
|
.expect_err("shared ancestor must fail even with ready marker");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700)).expect("restore state ancestor");
|
||||||
|
|
||||||
|
let linked_ancestor = temp.path().join("linked-ancestor");
|
||||||
|
symlink(&ancestor, &linked_ancestor).expect("create ancestor symlink");
|
||||||
|
let error = prepare_state_directory_with_sync(&linked_ancestor.join("state"), |_| {
|
||||||
|
panic!("symlink ancestor must fail before syncing")
|
||||||
|
})
|
||||||
|
.expect_err("symlink ancestor must fail even with ready marker");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_preparation_publishes_one_safe_ready_marker() {
|
||||||
|
let temp = secure_tempdir();
|
||||||
|
let state_parent = temp.path().join("preprovisioned");
|
||||||
|
fs::create_dir(&state_parent).expect("preprovision state parent");
|
||||||
|
fs::set_permissions(&state_parent, fs::Permissions::from_mode(0o700)).expect("secure state parent");
|
||||||
|
let state = state_parent.join("state");
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let handles = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
scope.spawn(move || {
|
||||||
|
let first_sync = Cell::new(true);
|
||||||
|
prepare_state_directory_with_sync(&state, |path| {
|
||||||
|
if first_sync.replace(false) {
|
||||||
|
barrier.wait();
|
||||||
|
}
|
||||||
|
sync_directory(path)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for handle in handles {
|
||||||
|
let prepared = handle
|
||||||
|
.join()
|
||||||
|
.expect("preparation thread")
|
||||||
|
.expect("concurrent preparation succeeds");
|
||||||
|
assert_eq!(prepared, state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(ready_marker_exists(&state.join(BOOTSTRAP_READY_FILE)).expect("validate ready marker"));
|
||||||
|
let staging = fs::read_dir(&state)
|
||||||
|
.expect("read state directory")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.any(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"));
|
||||||
|
assert!(!staging, "concurrent publication must not leave staging files");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, not(unix)))]
|
#[cfg(all(test, not(unix)))]
|
||||||
|
|||||||
@@ -456,7 +456,11 @@ async fn endpoint_ca_token_state_and_service_failures_are_closed_and_sanitized()
|
|||||||
.await
|
.await
|
||||||
.expect_err("malformed token must fail");
|
.expect_err("malformed token must fail");
|
||||||
assert!(matches!(error, RegistrationBootstrapError::Token(_)));
|
assert!(matches!(error, RegistrationBootstrapError::Token(_)));
|
||||||
assert!(!malformed_state.exists());
|
assert_eq!(
|
||||||
|
fs::read(malformed_state.join(".bootstrap-ready")).expect("read durable state marker"),
|
||||||
|
b"v1\n"
|
||||||
|
);
|
||||||
|
assert_no_staging_files(&malformed_state);
|
||||||
|
|
||||||
let expired = temp.path().join("expired-token.json");
|
let expired = temp.path().join("expired-token.json");
|
||||||
write_file(&expired, &token_document(OffsetDateTime::now_utc().unix_timestamp() - 1), 0o600);
|
write_file(&expired, &token_document(OffsetDateTime::now_utc().unix_timestamp() - 1), 0o600);
|
||||||
@@ -522,6 +526,44 @@ async fn token_ca_and_state_paths_reject_sharing_symlinks_and_non_files() {
|
|||||||
}
|
}
|
||||||
fs::set_permissions(&root, fs::Permissions::from_mode(0o644)).expect("restore CA mode");
|
fs::set_permissions(&root, fs::Permissions::from_mode(0o644)).expect("restore CA mode");
|
||||||
|
|
||||||
|
let missing_parent = temp.path().join("missing-parent");
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o640)).expect("make token unsafe behind parent gate");
|
||||||
|
let error = register_from_protected_input(endpoint, &root, &missing_parent.join("state"), Some(&token))
|
||||||
|
.await
|
||||||
|
.expect_err("missing state parent must fail before token access or network");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateParentRequired));
|
||||||
|
assert!(!missing_parent.exists(), "bootstrap must not create the durable parent");
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o600)).expect("restore token after parent gate");
|
||||||
|
|
||||||
|
let parent_state = temp.path().join("secure/connect/..");
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o640)).expect("make token unsafe behind state gate");
|
||||||
|
let error = register_from_protected_input(endpoint, &root, &parent_state, Some(&token))
|
||||||
|
.await
|
||||||
|
.expect_err("parent state component must fail before token access or network");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateDirectorySecurity));
|
||||||
|
assert!(!temp.path().join("secure").exists());
|
||||||
|
assert!(!parent_state.join(".bootstrap-ready").exists());
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o600)).expect("restore token after state gate");
|
||||||
|
|
||||||
|
let marker_state = temp.path().join("unsafe-marker-state");
|
||||||
|
for directory in [
|
||||||
|
marker_state.clone(),
|
||||||
|
marker_state.join("identity"),
|
||||||
|
marker_state.join("credential"),
|
||||||
|
] {
|
||||||
|
fs::create_dir_all(&directory).expect("create marker state directory");
|
||||||
|
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).expect("secure marker state directory");
|
||||||
|
}
|
||||||
|
let marker_target = temp.path().join("unsafe-marker-target");
|
||||||
|
write_file(&marker_target, b"v1\n", 0o600);
|
||||||
|
symlink(marker_target, marker_state.join(".bootstrap-ready")).expect("ready marker symlink");
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o640)).expect("make token unsafe behind marker gate");
|
||||||
|
let error = register_from_protected_input(endpoint, &root, &marker_state, Some(&token))
|
||||||
|
.await
|
||||||
|
.expect_err("unsafe marker must fail before token access or network");
|
||||||
|
assert!(matches!(error, RegistrationBootstrapError::StateMarkerSecurity));
|
||||||
|
fs::set_permissions(&token, fs::Permissions::from_mode(0o600)).expect("restore token after marker gate");
|
||||||
|
|
||||||
let shared_state = temp.path().join("shared-state");
|
let shared_state = temp.path().join("shared-state");
|
||||||
fs::create_dir(&shared_state).expect("shared state directory");
|
fs::create_dir(&shared_state).expect("shared state directory");
|
||||||
fs::set_permissions(&shared_state, fs::Permissions::from_mode(0o770)).expect("make state group-writable");
|
fs::set_permissions(&shared_state, fs::Permissions::from_mode(0o770)).expect("make state group-writable");
|
||||||
@@ -616,6 +658,7 @@ fn assert_owner_only_files(state: &std::path::Path) {
|
|||||||
|
|
||||||
for path in [
|
for path in [
|
||||||
state.to_path_buf(),
|
state.to_path_buf(),
|
||||||
|
state.join(".bootstrap-ready"),
|
||||||
state.join("identity"),
|
state.join("identity"),
|
||||||
state.join("credential"),
|
state.join("credential"),
|
||||||
state.join("identity/device.key"),
|
state.join("identity/device.key"),
|
||||||
|
|||||||
Reference in New Issue
Block a user