Merge pull request #7168 from gaord/feat/hmdfs-ownerless-mount

fix(runtime): keep private state on a filesystem that cannot express ownership
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-11-01 04:43:11 +08:00
commit f36e46ddf9
38 files changed
+2739 -281

No files matched your search

+9 -1
View File
@@ -230,7 +230,15 @@ mod platform {
#[cfg(windows)]
let name = "daemon.owner.json".to_string();
owner_work(move || {
let parent = match PrivateDirectory::inspect(&directory) {
// This runs before the engine has anywhere to log, so a refusal
// here is the first thing a user sees and the last thing they can
// diagnose. A mount that reports its own ownership (HarmonyOS
// hmdfs) makes the strict proof impossible for every object on it,
// so discovering an owner — not authenticating one — takes the
// measured exception. The receipt is still read through the
// retained handle, must be a single-link file, and the socket
// identity is still compared.
let parent = match PrivateDirectory::inspect_trusting_mount(&directory) {
Ok(parent) => Arc::new(parent),
Err(error)
if error
+95 -13
View File
@@ -107,8 +107,28 @@ pub enum DaemonSocketError {
source: std::io::Error,
},
/// The app-server state (config, state store, runtime) failed to build.
///
/// The source carries the real cause; a caller that prints only this
/// message hides it. `serve --http` on a mount where the endpoint
/// directory cannot be private used to surface here as a bare
/// "failed to build daemon state", which named neither the directory nor
/// the reason — see [`Self::EndpointDirectoryUnsupported`].
#[error("failed to build daemon state")]
State(#[source] anyhow::Error),
/// The filesystem cannot hold the private directory an endpoint needs.
///
/// A private endpoint directory must be current-user-owned and closed to
/// group and other. HarmonyOS hmdfs — the 2in1 user directory — reports the
/// logged-in user's uid and a group-writable mode for every object created
/// on it, so `chmod` answers `EPERM` and no directory there can satisfy that
/// check.
/// This is a missing capability, not a refusal: an owner with an HTTP
/// frontend can run without its control socket, and does.
#[error(
"the filesystem cannot hold a private endpoint directory at {}: {reason}",
path.display()
)]
EndpointDirectoryUnsupported { path: PathBuf, reason: String },
}
/// How to start the daemon socket transport.
@@ -1028,26 +1048,44 @@ mod platform {
pub async fn bind_daemon_socket(
options: DaemonSocketOptions,
) -> Result<DaemonSocket, DaemonSocketError> {
bind_with_owner(options, None, None).await
// A daemon-only run has nothing else to serve with, so a mount that
// cannot carry the endpoint is a failure here rather than a degraded
// start. `bind_with_owner` reports it as such when `degrade` is false.
match bind_with_owner(options, None, None, false).await? {
Some(socket) => Ok(socket),
// A non-degrading bind reports a mount it cannot use as an error,
// so this is an internal invariant rather than a runtime state.
None => Err(DaemonSocketError::State(anyhow::anyhow!(
"a bind that cannot degrade returned no endpoint"
))),
}
}
/// Bind the control endpoint for an owner that also has an HTTP frontend.
///
/// `None` means this filesystem cannot hold a private endpoint directory.
/// The owner still serves HTTP, which is what its clients connect to; what
/// it loses is the socket a second client would use to discover it. The
/// store owner lock still keeps a second engine out of the same store, so
/// this degrades discovery, not mutual exclusion.
pub(crate) async fn bind_captured_owner(
state: AppState,
owner: super::RuntimeOwnerReceipt,
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
) -> Result<DaemonSocket, DaemonSocketError> {
) -> Result<Option<DaemonSocket>, DaemonSocketError> {
let options = DaemonSocketOptions {
socket_path: Some(owner.socket_path.clone()),
config_path: None,
};
bind_with_owner(options, Some((state, owner)), recovery).await
bind_with_owner(options, Some((state, owner)), recovery, true).await
}
async fn bind_with_owner(
options: DaemonSocketOptions,
captured: Option<(AppState, super::RuntimeOwnerReceipt)>,
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
) -> Result<DaemonSocket, DaemonSocketError> {
degrade: bool,
) -> Result<Option<DaemonSocket>, DaemonSocketError> {
let owner_state = captured.as_ref().map(|(state, _)| state.clone());
let path = resolve_socket_path(&SocketPathInputs::from_environment(options.socket_path)?)?;
let parent_path = path
@@ -1078,13 +1116,57 @@ mod platform {
}
previous.publication.parent.clone()
} else {
Arc::new(
socket_owner_work(owner_state.clone(), move || {
PrivateDirectory::admit(&parent_path)
})
.await
.map_err(DaemonSocketError::State)?,
)
// The strict admission first: on a filesystem that stores owner and
// mode it succeeds and nothing here costs anything extra. Only a
// failure asks the mount what it can express, which is what turns
// the opaque "failed to build daemon state" into either a reason to
// continue without the endpoint or a named refusal.
let admitted = socket_owner_work(owner_state.clone(), {
let parent_path = parent_path.clone();
move || PrivateDirectory::admit(&parent_path)
})
.await;
match admitted {
Ok(directory) => Arc::new(directory),
Err(error) => {
if let codewhale_config::private_directory::PrivateFooting::Synthesised {
observed_uid,
observed_mode,
} = socket_owner_work(owner_state.clone(), {
let parent_path = parent_path.clone();
move || {
Ok(codewhale_config::private_directory::mount_footing(
&parent_path,
))
}
})
.await
.map_err(DaemonSocketError::State)?
{
if degrade {
tracing::warn!(
endpoint = %path.display(),
"{} cannot provide workspace control-socket discovery: the \
filesystem reports uid {observed_uid} and mode \
{observed_mode:o} for objects created on it, so a private \
endpoint directory cannot be verified here; serving the control \
API over HTTP only",
parent_path.display()
);
return Ok(None);
}
return Err(DaemonSocketError::EndpointDirectoryUnsupported {
path: parent_path.clone(),
reason: format!(
"the filesystem reports uid {observed_uid} and mode \
{observed_mode:o} for objects created on it, so a private \
endpoint directory cannot be verified there"
),
});
}
return Err(DaemonSocketError::State(error));
}
}
};
let name = path
.file_name()
@@ -1219,14 +1301,14 @@ mod platform {
.map_err(DaemonSocketError::State)?;
}
let (shutdown, _) = watch::channel(false);
Ok(DaemonSocket {
Ok(Some(DaemonSocket {
listener,
path,
state,
shutdown: Arc::new(shutdown),
socket_file,
owner,
})
}))
}
/// Only a definite refusal permits exact captured endpoint retirement.
+6 -1
View File
@@ -203,7 +203,11 @@ pub(crate) async fn bind_captured_owner(
state: AppState,
owner: RuntimeOwnerReceipt,
recovery: Option<Arc<UnavailablePublication>>,
) -> Result<DaemonSocket, DaemonSocketError> {
) -> Result<Option<DaemonSocket>, DaemonSocketError> {
// The named-pipe endpoint is bounded by the ACL the process applies, which
// NTFS always stores, so there is nothing a filesystem can fail to express
// here and nothing to degrade: `Some` is the only reachable answer. The
// shape matches the Unix one so the caller has a single code path.
bind(
DaemonSocketOptions {
socket_path: Some(owner.socket_path.clone()),
@@ -213,6 +217,7 @@ pub(crate) async fn bind_captured_owner(
recovery,
)
.await
.map(Some)
}
async fn bind(
options: DaemonSocketOptions,
+10 -2
View File
@@ -569,7 +569,15 @@ pub async fn bind_runtime_owner(
None,
)
.await?;
Ok(daemon)
// A caller of this entry point has no HTTP frontend to fall back on: it
// exists to hand back a socket. `bind_runtime_frontends` only degrades to
// `None` when the filesystem cannot hold a private endpoint directory, and
// there is nothing to serve if that happens here.
daemon.ok_or_else(|| {
anyhow::anyhow!(
"this filesystem cannot hold the private directory a runtime owner endpoint needs"
)
})
}
/// Build the actual compatibility state once for all owner frontends.
@@ -581,7 +589,7 @@ pub async fn bind_runtime_frontends(
routing: RuntimeOwnerRouting,
owner_frontend: Option<Arc<dyn RuntimeOwnerFrontend>>,
recovery: Option<Arc<daemon_client::UnavailablePublication>>,
) -> Result<(daemon_socket::DaemonSocket, AppState)> {
) -> Result<(Option<daemon_socket::DaemonSocket>, AppState)> {
anyhow::ensure!(
owner.version == 1 && owner.pid == std::process::id() && !owner.lease_generation.is_empty(),
"invalid captured Runtime owner"
+2 -1
View File
@@ -207,7 +207,7 @@ impl BootstrapPublicationFixture {
recovery,
)
.await?;
Ok(daemon)
Ok(daemon.expect("a private endpoint directory is available on this host"))
}
fn assert_publication(&self, bytes: &[u8]) {
@@ -1069,6 +1069,7 @@ async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wr
)
.await
.unwrap();
let daemon = daemon.expect("a private endpoint directory is available on this host");
let shutdown = daemon.shutdown_handle();
let socket = tokio::spawn(daemon.serve());
let selection = codewhale_app_server::ThreadControlSelection {
+15 -11
View File
@@ -356,18 +356,22 @@ pub fn with_config_write_lock<T>(
crate::quote_os_path(&lock_path)
)
})?;
// The lock is created at `0600`, so this only narrows a lock that an
// older version or a different umask left wider. It is applied to the
// handle that was just opened, not re-resolved by path: between the open
// and the `chmod` the path could name a different object, and this is the
// file the caller is about to lock. A mount that cannot apply modes is
// excused by `tighten_mode_of`; a filesystem that stores them keeps the
// error.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
lock_file
.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| {
format!(
"failed to secure config lock at {}",
crate::quote_os_path(&lock_path)
)
})?;
}
crate::private_directory::tighten_mode_of(&lock_file, &lock_path, 0o600).with_context(
|| {
format!(
"failed to secure config lock at {}",
crate::quote_os_path(&lock_path)
)
},
)?;
#[cfg(windows)]
validate_windows_lock_handle(&lock_file, &lock_path)?;
let mut lock = fd_lock::RwLock::new(lock_file);
+5 -5
View File
@@ -13,8 +13,6 @@ use sha2::{Digest as _, Sha256};
use std::ffi::OsStr;
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
/// Sibling `permissions.toml` schema.
@@ -442,10 +440,12 @@ pub(crate) fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
parent.display()
)
})?;
// A filesystem that assigns modes itself (HarmonyOS hmdfs) refuses the
// call; `tighten_mode` tolerates only that case and reports every other
// failure, so a permissions file that really is world-readable still
// fails here.
#[cfg(unix)]
temporary
.as_file()
.set_permissions(fs::Permissions::from_mode(0o600))
crate::private_directory::tighten_mode_of(temporary.as_file(), temporary.path(), 0o600)
.with_context(|| {
format!(
"failed to secure temporary permissions file for {}",
+8 -9
View File
@@ -26,9 +26,6 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// Restrictive file mode for setup-owned files (owner read/write only).
#[cfg(unix)]
const SETUP_FILE_MODE: u32 = 0o600;
@@ -56,13 +53,13 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
tmp.flush()
.with_context(|| format!("failed to flush temp file for {}", path.display()))?;
// A filesystem that assigns modes itself (HarmonyOS hmdfs) refuses the
// call outright; `tighten_mode` tolerates only that case and reports every
// other failure. The temp file sits in `path`'s directory, which is where
// the question about the mount is answered.
#[cfg(unix)]
{
let perms = fs::Permissions::from_mode(SETUP_FILE_MODE);
tmp.as_file()
.set_permissions(perms)
.with_context(|| format!("failed to set permissions for {}", path.display()))?;
}
crate::private_directory::tighten_mode_of(tmp.as_file(), tmp.path(), SETUP_FILE_MODE)
.with_context(|| format!("failed to set permissions for {}", path.display()))?;
tmp.as_file()
.sync_all()
@@ -369,6 +366,8 @@ mod tests {
#[cfg(unix)]
#[test]
fn atomic_write_uses_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
atomic_write(&path, b"x").unwrap();
File diff suppressed because it is too large. Load diff
+23 -6
View File
@@ -253,7 +253,8 @@ impl XaiOAuthCredentialStore {
let Some(mut file) = self.open_owned_file_for_read(name)? else {
return Ok(None);
};
let metadata = validate_owned_file_handle(&file, &self.directory.join(name))?;
let metadata =
validate_owned_file_handle(&file, &self.directory.join(name), self.private.guard())?;
if metadata.len() > XAI_OAUTH_FILE_LIMIT {
bail!(
"Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
@@ -625,9 +626,17 @@ fn is_chatgpt_owned_auth_name(name: &str) -> bool {
}
fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
// Sign-in has to work on a filesystem that cannot express ownership, or it
// cannot work there at all: HarmonyOS hmdfs reports the logged-in user's
// uid and a group-writable mode for every object an app creates and refuses
// `chmod`, so the strict proof can never hold. The trusting admission keeps
// the no-follow walk, the single-link check and atomic publication, and
// relies on the mount's own access control for the rest — the same tradeoff
// the file-backed secret store already makes on that mount, with the same
// warning.
Ok(XaiOAuthCredentialStore {
directory: directory.to_path_buf(),
private: crate::private_directory::PrivateDirectory::open(directory)?,
private: crate::private_directory::PrivateDirectory::open_trusting_mount(directory)?,
})
}
@@ -918,8 +927,12 @@ mod tests {
verify_windows_owner_only_handle(directory).expect("current-user-only directory");
let lock = store.open_lock_file().expect("create lifecycle lock");
validate_owned_file_handle(&lock, &credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME))
.expect("current-user-only lifecycle lock");
validate_owned_file_handle(
&lock,
&credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME),
store.private.guard(),
)
.expect("current-user-only lifecycle lock");
let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
store
@@ -929,8 +942,12 @@ mod tests {
.open_owned_file_for_read(generation)
.expect("open generation")
.expect("generation exists");
validate_owned_file_handle(&generation_file, &credentials.join(generation))
.expect("current-user-only generation");
validate_owned_file_handle(
&generation_file,
&credentials.join(generation),
store.private.guard(),
)
.expect("current-user-only generation");
}
#[cfg(windows)]
+62 -1
View File
@@ -15,6 +15,67 @@ use std::{
};
use uuid::Uuid;
/// Whether this path's directory refuses the mode a file is created with *and*
/// the one it is then asked for.
///
/// Some mounts synthesise mode bits instead of storing them: HarmonyOS hmdfs
/// (the 2in1 user directory) reports a group-readable mode for every file an
/// app creates, and `chmod` answers `EPERM` because that owner is not the
/// calling process. There the mode says nothing about who can
/// read the database, and refusing the store would leave the platform with no
/// memory database at all. This creates one probe file at `0600` beside `path`,
/// and — when the mode came back wider — asks the mount to narrow it and looks
/// again. A default ACL or a setgid parent widens a new file on a filesystem
/// that stores modes perfectly well; such a mount honours the second request
/// and keeps the strict refusal, because its mode really does describe access.
///
/// Any failure to run the probe answers `false`, so a directory we cannot write
/// to keeps the strict check rather than being reclassified as a mount limit.
///
/// The same rule, with the same reasoning, lives in
/// `codewhale_config::private_directory::private_footing`; this crate has no
/// dependency on it and the probe is a dozen lines, so it is repeated here
/// rather than widening this crate's dependency graph.
#[cfg(unix)]
fn mount_ignores_unix_modes(path: &Path) -> bool {
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return false;
};
let probe = parent.join(format!(
".codewhale-mode-probe-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.subsec_nanos())
));
let created = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&probe);
let Ok(file) = created else { return false };
let private = |file: &fs::File| {
file.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o077 == 0)
};
let wider = file
.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o077 != 0);
let refuses_narrowing = wider
&& match file.set_permissions(fs::Permissions::from_mode(0o600)) {
Ok(()) => !private(&file),
Err(_) => true,
};
drop(file);
let _ = fs::remove_file(&probe);
refuses_narrowing
}
/// Nesting-safe transaction scope for `&self` methods. rusqlite's
/// `unchecked_transaction` issues a literal `BEGIN`, which fails inside an
/// already-open transaction; engine methods compose inside each other's
@@ -331,7 +392,7 @@ impl Store {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
if meta.permissions().mode() & 0o077 != 0 && !mount_ignores_unix_modes(path) {
return Err(Error::Invalid(
"database permissions must exclude group and world access".into(),
));
+58 -8
View File
@@ -1254,22 +1254,51 @@ fn validate_plugin_state_directory_for_read(path: &Path) -> Result<(), String> {
.map_err(|error| format!("failed to inspect opened plugin state directory: {error}"))?;
// SAFETY: geteuid has no pointer or lifetime preconditions.
let effective_uid = unsafe { libc::geteuid() };
let is_directory = metadata.is_dir();
let public = metadata.uid() != effective_uid || metadata.permissions().mode() & 0o077 != 0;
// Only a directory that fails the fields asks the filesystem whether it can
// express them at all: on a mount that stores ownership this costs nothing,
// and on one that does not the answer — with its warning — is remembered
// per directory, so repeated reads do not repeat the probe or the log line.
let synthesised = is_directory
&& public
&& matches!(
codewhale_config::private_directory::mount_footing(path),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
);
validate_unix_plugin_state_directory_fields(
metadata.is_dir(),
is_directory,
metadata.uid(),
metadata.permissions().mode(),
effective_uid,
synthesised,
)
}
/// Whether a directory satisfies the private-state guard.
///
/// `mount_cannot_express_ownership` names a filesystem that reports its own
/// owner and mode for every object (HarmonyOS hmdfs), so neither field can
/// prove anything there. Refusing on such a mount makes plugin state
/// unusable for every app on that platform; accepting keeps the real guards —
/// no-follow, single link, atomic publication — and relies on the mount's own
/// access control for ownership, which is the same tradeoff the credential
/// store makes on the same mount.
#[cfg(unix)]
fn validate_unix_plugin_state_directory_fields(
is_directory: bool,
owner_uid: u32,
mode: u32,
effective_uid: u32,
mount_cannot_express_ownership: bool,
) -> Result<(), String> {
if !is_directory || owner_uid != effective_uid || mode & 0o077 != 0 {
if !is_directory {
return Err("Plugin state path must be a directory".to_string());
}
if mount_cannot_express_ownership {
return Ok(());
}
if owner_uid != effective_uid || mode & 0o077 != 0 {
return Err(
"Plugin state directory must be current-user-owned and inaccessible to group or other users"
.to_string(),
@@ -1328,8 +1357,11 @@ fn harden_opened_plugin_state_file(
#[cfg(unix)]
pub fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
// `tighten_mode` keeps the refusal for a filesystem that stores modes and
// tolerates one that assigns its own (HarmonyOS hmdfs), which is the same
// rule the state directory is admitted under. Refusing here would make a
// plugin state file unwritable after a successful write.
codewhale_config::private_directory::tighten_mode(path, 0o600).map_err(|error| {
format!(
"failed to restrict plugin state file permissions for {}: {error}",
path.display()
@@ -1987,10 +2019,9 @@ fn set_staged_read_only_file(path: &Path, source: &fs::Metadata) -> Result<(), S
#[cfg(unix)]
fn set_owner_only_directory(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|e| {
codewhale_config::private_directory::tighten_mode(path, 0o700).map_err(|error| {
format!(
"failed to restrict plugin runtime directory permissions for {}: {e}",
"failed to restrict plugin runtime directory permissions for {}: {error}",
path.display()
)
})
@@ -2565,9 +2596,28 @@ mod unix_state_directory_tests {
#[test]
fn state_directory_validation_rejects_an_owner_mismatch() {
let error = validate_unix_plugin_state_directory_fields(true, 41, 0o700, 42).unwrap_err();
let error =
validate_unix_plugin_state_directory_fields(true, 41, 0o700, 42, false).unwrap_err();
assert!(error.contains("current-user-owned"));
}
/// A mount that reports its own owner and mode cannot prove either, so the
/// fields are not evidence there. The refusal above must still apply on a
/// filesystem that stores them — this pins that the tolerance is a property
/// of the mount, not a weakening of the check.
#[test]
fn state_directory_validation_accepts_a_mount_that_cannot_express_ownership() {
validate_unix_plugin_state_directory_fields(true, 41, 0o2771, 42, true)
.expect("a synthesising mount is not an owner mismatch");
assert!(
validate_unix_plugin_state_directory_fields(true, 41, 0o2771, 42, false).is_err(),
"the same values are a refusal where the mount stores them"
);
assert!(
validate_unix_plugin_state_directory_fields(false, 42, 0o700, 42, true).is_err(),
"a non-directory is refused whatever the mount does"
);
}
}
#[cfg(all(test, windows))]
+5 -2
View File
@@ -916,8 +916,11 @@ fn make_tree_removable(path: &Path) -> io::Result<()> {
if metadata.is_dir() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
// Removal is the goal here; a mount that assigns its own modes
// already leaves the owner able to delete, and `tighten_mode`
// keeps the error for a filesystem that stores modes.
codewhale_config::private_directory::tighten_mode(path, 0o700)
.map_err(|error| io::Error::other(error.to_string()))?;
}
#[cfg(not(unix))]
{
+27 -4
View File
@@ -229,10 +229,15 @@ fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result
if require_owner_only {
use std::os::unix::fs::MetadataExt as _;
// SAFETY: geteuid(2) dereferences no pointers.
if metadata.uid() != unsafe { libc::geteuid() }
|| metadata.mode() & 0o077 != 0
|| metadata.nlink() != 1
{
let public = metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0;
// A mount that reports ownership of its own (HarmonyOS hmdfs) makes
// both the owner and the mode meaningless as evidence, so a real
// disagreement is only a refusal when the mount can express the fields
// it is being judged on. Asking costs a probe, so it is asked only
// here — after the cheap comparison has already failed — and the
// single-link requirement, which the mount does store, always holds.
let ownership_unproven = public && !mount_cannot_express_ownership(path);
if ownership_unproven || metadata.nlink() != 1 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Codewhale-owned credential file must be singly linked, owned by this user, and mode 0600 or stricter",
@@ -242,6 +247,24 @@ fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result
Ok(current)
}
/// Whether this path's filesystem reports an owner and mode of its own for
/// every object, so neither can serve as evidence of who may read it.
///
/// HarmonyOS hmdfs (the 2in1 user directory) is that case: it reports the
/// logged-in user's uid and a group-writable mode for objects the app creates,
/// and `chmod` answers `EPERM` because that owner is not the caller. The guard
/// is unprovable there for *every* caller, so
/// refusing would make the feature unusable on the platform; what remains is
/// the mount's own access control plus the single-link check.
#[cfg(unix)]
fn mount_cannot_express_ownership(path: &std::path::Path) -> bool {
let directory = path.parent().unwrap_or(path);
matches!(
codewhale_config::private_directory::mount_footing(directory),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
)
}
#[cfg(windows)]
fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result<File> {
use std::ffi::OsString;
+20 -2
View File
@@ -31,11 +31,29 @@ pub fn private_log_options() -> std::fs::OpenOptions {
/// Tighten a log created by an earlier version at the default mode. No-op
/// outside Unix.
///
/// Best effort on purpose. The file was created `0600`, so this only narrows a
/// log left wider by an earlier version or a different umask — and a mount that
/// assigns modes itself (HarmonyOS hmdfs answers `EPERM` to every `chmod`) will
/// refuse it for every object it holds. Failing here would turn a write that
/// already succeeded into "permission denied"; a log left wide is reported
/// instead, so an operator can still see it.
pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
use std::os::unix::fs::PermissionsExt as _;
if file
.set_permissions(std::fs::Permissions::from_mode(0o600))
.is_err()
&& file
.metadata()
.is_ok_and(|metadata| metadata.permissions().mode() & 0o077 != 0)
{
tracing::warn!(
"a private log could not be narrowed to 0600 and is still readable by group or \
other; this filesystem does not apply Unix mode bits"
);
}
}
#[cfg(not(unix))]
let _ = file;
+9 -2
View File
@@ -196,12 +196,19 @@ fn open_log_file(path: &Path) -> std::io::Result<std::fs::File> {
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
use std::os::unix::fs::OpenOptionsExt as _;
options
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
let file = options.open(path)?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
// Through the handle that was opened `O_NOFOLLOW`: re-resolving `path`
// here could narrow a substitute, and this log is the object the caller
// is about to write prompts and paths into. `tighten_mode_of` keeps the
// refusal for a filesystem that stores modes and excuses only one that
// cannot apply them, so a log still readable by group or other is
// reported rather than ignored.
codewhale_config::private_directory::tighten_mode_of(&file, path, 0o600)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(file)
}
#[cfg(not(unix))]
+240 -54
View File
@@ -17,6 +17,19 @@
//! releases when the holder dies, plus a small holder record a contender can
//! read to name the holder in a legible refusal. The kernel lock is the
//! authority; recorded holder facts never authorize replacing its file.
//!
//! Two capabilities are involved and they are asked about separately. The
//! directory admission needs ownership it can *prove*; HarmonyOS hmdfs — the
//! 2in1 user directory — synthesises the owner and mode of every object created
//! on it, so that proof can never hold there. Exclusivity itself is the kernel
//! lock, which is a different capability and does hold there. Admission
//! therefore relaxes only the proof, and the lease is still taken; refusing on
//! the proof turned a limit on evidence into a failed engine start.
//!
//! A mount that refuses the lock itself is the one case where exclusivity is
//! genuinely unavailable. It is reported to the caller — three outcomes, of
//! which only contention is a refusal — so that decision is named rather than
//! silently taken.
use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
@@ -118,17 +131,91 @@ struct LeaseInner {
state: std::sync::Mutex<Option<(WorkspaceDriveLease, usize)>>,
}
/// Why the lease could not be taken even though no other process holds it.
///
/// This is a capability the filesystem lacks, not a refusal: the caller decides
/// whether to run without workspace exclusivity, and the reason is carried so
/// that decision can be explained to the user instead of looking like success.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeaseUnavailability {
/// The lease state root whose filesystem could not carry the lease.
pub directory: PathBuf,
/// What the mount did, in terms a user can act on.
pub reason: String,
}
impl LeaseUnavailability {
#[cfg(unix)]
fn new(directory: &Path, reason: impl Into<String>) -> Self {
Self {
directory: directory.to_path_buf(),
reason: reason.into(),
}
}
}
impl std::fmt::Display for LeaseUnavailability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} cannot provide workspace exclusivity: {}",
self.directory.display(),
self.reason
)
}
}
/// One raw attempt to take a workspace's drive lease.
///
/// Public because [`WorkspaceDriveLease::try_acquire`] is: it is how an
/// embedder that wants the lease without the shared-slot bookkeeping asks.
pub enum LeaseAttempt {
Acquired(WorkspaceDriveLease),
/// A foreign process holds the kernel lock.
Contended,
/// No lock can be taken here at all.
Unsupported(LeaseUnavailability),
}
/// The outcome of admitting one driving engine for a workspace.
pub enum LeaseAdmission {
/// This process holds (or shares) the workspace drive lease.
Held(SharedWorkspaceDriveLease),
/// Another process drives this workspace: refuse and name its holder.
Contended,
/// This filesystem cannot carry an exclusive lease. Running without one is
/// the caller's decision, and must be reported rather than assumed.
Unsupported(LeaseUnavailability),
}
/// The held handle is a guard, not a fact worth printing; the other two
/// outcomes are what a caller or a test needs to see.
impl std::fmt::Debug for LeaseAdmission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Held(_) => f.write_str("Held(..)"),
Self::Contended => f.write_str("Contended"),
Self::Unsupported(unavailable) => {
f.debug_tuple("Unsupported").field(unavailable).finish()
}
}
}
}
impl SharedWorkspaceDriveLease {
/// Acquire (or join) this process's drive lease for the workspace.
///
/// `None` means a *foreign process* holds it — the caller refuses with the
/// holder's facts from [`WorkspaceDriveLease::read_holder`]. Same-process
/// joins never fail and never re-lock.
/// [`LeaseAdmission::Contended`] means a *foreign process* holds it — the
/// caller refuses with the holder's facts from
/// [`WorkspaceDriveLease::read_holder`]. [`LeaseAdmission::Unsupported`]
/// means the filesystem cannot carry the lease at all, which is a different
/// answer and a different decision. Same-process joins never fail and never
/// re-lock.
pub fn acquire(
workspace: &Path,
client: Option<&str>,
store_root: Option<&Path>,
) -> Result<Option<Self>> {
) -> Result<LeaseAdmission> {
let lease_key = WorkspaceDriveLease::lease_path(workspace)?;
let slot = {
let mut registry = registry()
@@ -137,15 +224,18 @@ impl SharedWorkspaceDriveLease {
if let Some(slot) = registry.get(&lease_key) {
slot.clone()
} else {
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)?
else {
return Ok(None);
let lease = match WorkspaceDriveLease::try_acquire(workspace, client, store_root)? {
LeaseAttempt::Acquired(lease) => lease,
LeaseAttempt::Contended => return Ok(LeaseAdmission::Contended),
LeaseAttempt::Unsupported(unavailable) => {
return Ok(LeaseAdmission::Unsupported(unavailable));
}
};
let inner = std::sync::Arc::new(LeaseInner {
state: std::sync::Mutex::new(Some((lease, 1))),
});
registry.insert(lease_key, inner.clone());
return Ok(Some(Self { inner }));
return Ok(LeaseAdmission::Held(Self { inner }));
}
};
// The returned handle is an owned clone, so moving it out never
@@ -160,13 +250,17 @@ impl SharedWorkspaceDriveLease {
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((_lease, count)) = state.as_mut() {
*count += 1;
return Ok(Some(Self { inner }));
return Ok(LeaseAdmission::Held(Self { inner }));
}
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)? else {
return Ok(None);
let lease = match WorkspaceDriveLease::try_acquire(workspace, client, store_root)? {
LeaseAttempt::Acquired(lease) => lease,
LeaseAttempt::Contended => return Ok(LeaseAdmission::Contended),
LeaseAttempt::Unsupported(unavailable) => {
return Ok(LeaseAdmission::Unsupported(unavailable));
}
};
*state = Some((lease, 1));
Ok(Some(Self { inner }))
Ok(LeaseAdmission::Held(Self { inner }))
}
}
@@ -239,24 +333,32 @@ impl WorkspaceDriveLease {
/// Try once to acquire the drive lease for this process.
///
/// `None` on contention: the kernel lock is held. Use
/// [`Self::read_holder`] to name the recorded holder.
/// [`LeaseAttempt::Contended`] means the kernel lock is held: use
/// [`Self::read_holder`] to name the recorded holder. An unsupported mount
/// is reported, not refused.
pub fn try_acquire(
workspace: &Path,
client: Option<&str>,
store_root: Option<&Path>,
) -> Result<Option<Self>> {
) -> Result<LeaseAttempt> {
let path = Self::lease_path(workspace)?;
let parent = path.parent().context("Workspace lease has no parent")?;
let base = parent
let state_root = parent
.parent()
.context("Workspace lease has no state root")?;
let name = path
.file_name()
.and_then(|name| name.to_str())
.context("Workspace lease name is invalid")?;
let base = PrivateDirectory::admit(base)?;
let directory = PrivateDirectory::admit(parent)?;
// Admission relaxes *only* the proof that the directory is
// current-user-only, and only where the mount cannot express it. The
// exclusivity this function exists for is the kernel lock taken below,
// which is a different capability: on HarmonyOS hmdfs `flock` works
// and only the reported owner is unusable, so giving up the lease here
// would discard a guarantee that still holds. A mount that truly has
// no advisory locks is reported further down, after the lock says so.
let base = PrivateDirectory::admit_trusting_mount(state_root)?;
let directory = PrivateDirectory::admit_trusting_mount(parent)?;
let file = directory.open_internal_file(name)?;
#[cfg(unix)]
let contention = {
@@ -293,10 +395,20 @@ impl WorkspaceDriveLease {
#[cfg(not(any(unix, windows)))]
let contention: Option<std::io::Error> = None;
if let Some(error) = contention {
if !codewhale_util::lock::is_contention(&error) {
return Err(error).context("Failed to lock workspace lease");
if codewhale_util::lock::is_contention(&error) {
return Ok(LeaseAttempt::Contended);
}
return Ok(None);
// No advisory locks on this mount at all: two drivers cannot be
// excluded here, but that is a property of the filesystem, not a
// holder. `EWOULDBLOCK` was handled above and stays contention.
#[cfg(unix)]
if codewhale_util::lock::advisory_locking_unavailable(&error) {
return Ok(LeaseAttempt::Unsupported(LeaseUnavailability::new(
state_root,
format!("this filesystem refuses advisory file locks ({error})"),
)));
}
return Err(error).context("Failed to lock workspace lease");
}
#[cfg(any(unix, windows))]
anyhow::ensure!(
@@ -328,7 +440,7 @@ impl WorkspaceDriveLease {
.set_len(0)
.and_then(|()| lease.file.seek(SeekFrom::Start(0)).map(|_| ()))
.and_then(|()| lease.file.write_all(holder.to_record().as_bytes()));
Ok(Some(lease))
Ok(LeaseAttempt::Acquired(lease))
}
/// Read the recorded holder, if any. Missing or malformed data is `None`;
@@ -340,7 +452,11 @@ impl WorkspaceDriveLease {
.file_name()
.and_then(|name| name.to_str())
.context("Workspace lease name is invalid")?;
let directory = match PrivateDirectory::inspect(parent) {
// Naming a holder is a read. On a mount that cannot express ownership
// the strict proof fails for every directory, and refusing would hide
// the holder's pid behind "record unreadable" on exactly the platform
// where a user most needs to know who holds the workspace.
let directory = match PrivateDirectory::inspect_trusting_mount(parent) {
Ok(directory) => directory,
Err(error)
if error.chain().any(|cause| {
@@ -457,9 +573,6 @@ fn current_process_start() -> Option<String> {
None
}
/// The base for lease files. Test isolation mirrors `snapshot::paths`: an
/// unsealed test gets a private base, so a lease taken by a fixture never
/// lands in the developer's real state dir.
fn lease_state_base() -> Result<PathBuf> {
#[cfg(test)]
{
@@ -497,15 +610,31 @@ mod tests {
path
}
fn expect_held(admission: LeaseAdmission) -> SharedWorkspaceDriveLease {
match admission {
LeaseAdmission::Held(lease) => lease,
LeaseAdmission::Contended => panic!("expected a held lease, got contention"),
LeaseAdmission::Unsupported(unavailable) => {
panic!("expected a held lease, got {unavailable}")
}
}
}
#[test]
fn acquire_then_contend_reports_holder() {
let workspace = temp_workspace("contend");
let first =
WorkspaceDriveLease::try_acquire(&workspace, Some("tui"), None).expect("first acquire");
assert!(first.is_some(), "first drive is admitted");
assert!(
matches!(first, LeaseAttempt::Acquired(_)),
"first drive is admitted"
);
let second = WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None)
.expect("contend probe");
assert!(second.is_none(), "second drive must be refused");
assert!(
matches!(second, LeaseAttempt::Contended),
"second drive must be refused"
);
let holder = WorkspaceDriveLease::read_holder(&workspace)
.expect("read holder")
.expect("holder recorded");
@@ -515,29 +644,38 @@ mod tests {
drop(first);
let third =
WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None).expect("reacquire");
assert!(third.is_some(), "lease frees after drop");
assert!(
matches!(third, LeaseAttempt::Acquired(_)),
"lease frees after drop"
);
}
#[test]
fn shared_lease_reenters_within_a_process() {
let workspace = temp_workspace("shared");
let first = SharedWorkspaceDriveLease::acquire(&workspace, Some("tui"), None)
.expect("first shared acquire")
.expect("lease taken");
let first = expect_held(
SharedWorkspaceDriveLease::acquire(&workspace, Some("tui"), None)
.expect("first shared acquire"),
);
// A second engine in the same process joins by refcount instead of
// refusing — the lease's unit is the process.
let second = SharedWorkspaceDriveLease::acquire(&workspace, Some("vscode"), None)
.expect("second shared acquire")
.expect("same-process join");
let second = expect_held(
SharedWorkspaceDriveLease::acquire(&workspace, Some("vscode"), None)
.expect("second shared acquire"),
);
drop(first);
// Still held by the second handle: a raw probe must be refused.
let refused =
WorkspaceDriveLease::try_acquire(&workspace, Some("probe"), None).expect("probe");
assert!(refused.is_none(), "lease still held while shared");
assert!(
matches!(refused, LeaseAttempt::Contended),
"lease still held while shared"
);
drop(second);
let after = SharedWorkspaceDriveLease::acquire(&workspace, Some("next"), None)
.expect("reacquire after full release")
.expect("lease freed after last handle");
let after = expect_held(
SharedWorkspaceDriveLease::acquire(&workspace, Some("next"), None)
.expect("reacquire after full release"),
);
drop(after);
}
@@ -553,9 +691,10 @@ mod tests {
let workspace = workspace.clone();
workers.push(std::thread::spawn(move || {
for _ in 0..25 {
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("churn"), None)
.expect("acquire")
.expect("same-process join");
let lease = expect_held(
SharedWorkspaceDriveLease::acquire(&workspace, Some("churn"), None)
.expect("acquire"),
);
drop(lease);
}
}));
@@ -565,9 +704,10 @@ mod tests {
}
// Every handle is gone: the lease is free and a fresh holder records
// itself rather than joining a slot nothing released.
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("after"), None)
.expect("acquire after churn")
.expect("the lease is free after the last handle");
let lease = expect_held(
SharedWorkspaceDriveLease::acquire(&workspace, Some("after"), None)
.expect("acquire after churn"),
);
let holder = WorkspaceDriveLease::read_holder(&workspace)
.expect("read holder")
.expect("holder recorded");
@@ -575,6 +715,45 @@ mod tests {
drop(lease);
}
/// The one branch that decides fatal from degraded. A mount with no
/// advisory locks must never be reported as a second driver — that would
/// keep the engine from starting over a property of the filesystem — and a
/// second driver must never be downgraded to "run anyway", which would let
/// two engines write one tree. The two predicates are disjoint by
/// construction; this pins that.
#[cfg(unix)]
#[test]
fn only_a_held_lock_is_contention_and_only_a_lockless_mount_is_unsupported() {
use std::io::Error;
let held = Error::from_raw_os_error(libc::EWOULDBLOCK);
assert!(
codewhale_util::lock::is_contention(&held),
"a held lock is contention"
);
assert!(
!codewhale_util::lock::advisory_locking_unavailable(&held),
"a held lock is not a mount without locks"
);
for code in [
libc::EPERM,
libc::EACCES,
libc::ENOTSUP,
libc::EINVAL,
libc::ENOSYS,
] {
let absent = Error::from_raw_os_error(code);
assert!(
!codewhale_util::lock::is_contention(&absent),
"{code} is the mount answering, not a holder"
);
assert!(
codewhale_util::lock::advisory_locking_unavailable(&absent),
"{code} means this mount has no advisory locks"
);
}
}
#[test]
fn lease_path_is_stable_and_worktree_aware() {
let workspace = temp_workspace("paths");
@@ -679,21 +858,28 @@ mod tests {
let workspace = temp_workspace("dead");
holder_of_a_reaped_child(&workspace);
let first = WorkspaceDriveLease::try_acquire(&workspace, Some("next"), None)
.expect("acquire")
.expect("an unlocked lease remains acquirable despite a stale record");
let first =
WorkspaceDriveLease::try_acquire(&workspace, Some("next"), None).expect("acquire");
assert!(
matches!(first, LeaseAttempt::Acquired(_)),
"an unlocked lease remains acquirable despite a stale record"
);
holder_of_a_reaped_child(&workspace);
assert!(
WorkspaceDriveLease::try_acquire(&workspace, Some("contender"), None)
.expect("contend")
.is_none(),
matches!(
WorkspaceDriveLease::try_acquire(&workspace, Some("contender"), None)
.expect("contend"),
LeaseAttempt::Contended
),
"a stale holder record must not replace a held kernel lock"
);
drop(first);
assert!(
WorkspaceDriveLease::try_acquire(&workspace, Some("after"), None)
.expect("reacquire")
.is_some(),
matches!(
WorkspaceDriveLease::try_acquire(&workspace, Some("after"), None)
.expect("reacquire"),
LeaseAttempt::Acquired(_)
),
"the same lease is available after its holder releases it"
);
}
+408 -14
View File
@@ -4,7 +4,11 @@
use crate::SecretsError;
use std::{fs, path::Path};
pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, SecretsError> {
pub(crate) fn open_private(
path: &Path,
create: bool,
role: StoreFile,
) -> Result<fs::File, SecretsError> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() || !meta.is_file() => {
return Err(std::io::Error::other("The secret store must be a regular file.").into());
@@ -35,16 +39,9 @@ pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, Secret
return Err(std::io::Error::other("The secret store must be a regular file.").into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SecretsError::InsecurePermissions {
path: path.into(),
mode,
});
}
}
require_private_mode(&file, path, role)?;
#[cfg(not(unix))]
let _ = role;
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
@@ -57,6 +54,169 @@ pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, Secret
Ok(file)
}
/// Which of the store's two files is being opened.
///
/// The distinction decides whether a wider-than-`0600` mode is repaired or
/// refused, and it is not cosmetic: one file carries the secret and the other
/// carries nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StoreFile {
/// `secrets.json`. A mode that let another user in means the bytes may no
/// longer be ours, so the file is refused rather than quietly re-narrowed —
/// repairing the mode would hide exactly the question the caller must
/// answer. The refusal names the mode it saw.
Content,
/// The `<store>.lock` sibling. It holds no secret and exists only to
/// serialise writers, so a wider mode is hygiene: it is narrowed in place
/// before use, and only a filesystem that cannot hold modes is accepted as
/// it is.
Lock,
}
/// Make sure a secret-store file is readable by its owner only.
///
/// 1. Already `0o600` or narrower: done, no syscall and no probe.
/// 2. A lock file that is wider: narrow it through the handle that was opened
/// and look again. A repair that does not take effect falls through.
/// 3. Still wider, and the mount cannot apply Unix mode bits — HarmonyOS hmdfs
/// reports the logged-in user as the owner of every object, so a process is
/// never the owner of what it creates and `chmod` answers `EPERM`; FUSE and
/// some network mounts refuse it too: accept with a warning, because
/// refusing leaves that platform with no store at all and the fallback is a
/// plaintext key in `config.toml`, a wider exposure.
/// 4. Still wider on a filesystem that does store modes: a real exposure, and
/// still refused.
#[cfg(unix)]
pub(crate) fn require_private_mode(
file: &fs::File,
path: &Path,
role: StoreFile,
) -> Result<(), SecretsError> {
use std::os::unix::fs::PermissionsExt;
let observed = file.metadata()?.permissions().mode() & 0o777;
if observed & 0o077 == 0 {
return Ok(());
}
// Narrowing through the handle keeps it on the object that was opened, so a
// substituted path cannot be the thing that gets changed. Only the lock is
// repaired; see [`StoreFile`] for why the content is not.
let narrowed = match role {
StoreFile::Lock => file
.set_permissions(fs::Permissions::from_mode(0o600))
.and_then(|()| file.metadata())
.map(|metadata| metadata.permissions().mode() & 0o777)
.ok(),
StoreFile::Content => None,
};
// The probe costs a create, a stat and an unlink, so it runs only when the
// mode is still wide after any repair.
let mount_cannot_hold_modes = match narrowed {
Some(mode) if mode & 0o077 == 0 => false,
_ => mount_ignores_unix_modes(path),
};
match classify_store_mode(observed, narrowed, mount_cannot_hold_modes) {
StoreMode::Narrowed => Ok(()),
StoreMode::Unavoidable { mode } => {
tracing::warn!(
"{} has mode {mode:o} and its filesystem does not apply Unix mode bits; \
relying on the mount's own access control for the secret store",
path.display()
);
Ok(())
}
StoreMode::Exposed { mode } => Err(SecretsError::InsecurePermissions {
path: path.into(),
mode,
}),
}
}
/// What a store file's mode turned out to be worth, given what the mount did
/// when asked for `0o600`.
///
/// Separated from the syscalls so every branch is testable: the interesting
/// cases need a mount that either refuses `chmod` or accepts it without
/// applying it, which no portable filesystem produces.
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StoreMode {
/// Narrower than `0o600` after the repair.
Narrowed,
/// Wider, and this filesystem cannot represent the mode at all.
Unavoidable { mode: u32 },
/// Wider, on a filesystem that does store modes: still a refusal.
Exposed { mode: u32 },
}
#[cfg(unix)]
fn classify_store_mode(
observed: u32,
narrowed: Option<u32>,
mount_cannot_hold_modes: bool,
) -> StoreMode {
if narrowed.is_some_and(|mode| mode & 0o077 == 0) {
return StoreMode::Narrowed;
}
let mode = narrowed.unwrap_or(observed);
if mount_cannot_hold_modes {
StoreMode::Unavoidable { mode }
} else {
StoreMode::Exposed { mode }
}
}
/// Whether `path`'s directory refuses the mode a file is created with *and* the
/// one it is then asked for.
///
/// Creates one probe file beside `path`, the same way the store creates its
/// own, at `0o600`, and reads back the mode the mount stored. A filesystem that
/// keeps mode bits reports `0o600` or narrower. A wider mode alone is not
/// enough to blame the mount — a default ACL or a setgid parent widens a new
/// file on a filesystem that stores modes perfectly well — so the probe asks
/// the mount to narrow it and looks again; only a refusal, or a mode that stays
/// wide, means the mode says nothing about who may read the file. Any failure
/// to run the probe answers `false`, so a directory we cannot create files in
/// keeps the strict check instead of widening it.
///
/// Only a store file whose mode is wider than `0o600` reaches this, so a
/// filesystem that stores modes never pays for it; on the mounts that do reach
/// it, the cost is one create, one stat, at most one `chmod` and one unlink.
///
/// The same rule, with the same reasoning, lives in
/// `codewhale_config::private_directory::private_footing`. This crate is below
/// `codewhale-config` in the graph, so it cannot call it; the probe is a dozen
/// lines and the alternative is a dependency cycle. `scripts/ohos-secrets-store-smoke.sh`
/// covers the synthesising branch end to end against a real mount; the tests
/// here cover everything a portable filesystem can produce.
#[cfg(unix)]
fn mount_ignores_unix_modes(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
return false;
};
let Ok(probe) = tempfile::Builder::new()
.prefix(".codewhale-mode-probe-")
.tempfile_in(parent)
else {
return false;
};
let narrows = |file: &fs::File| {
file.set_permissions(fs::Permissions::from_mode(0o600))
.is_ok_and(|()| {
file.metadata()
.is_ok_and(|meta| meta.permissions().mode() & 0o077 == 0)
})
};
match probe.as_file().metadata() {
// `0o077` clear means the mount stored the `0o600` we created with.
Ok(meta) if meta.permissions().mode() & 0o077 == 0 => false,
Ok(_) => !narrows(probe.as_file()),
Err(_) => false,
}
}
pub(crate) fn with_write_lock<T>(
path: &Path,
operation: impl FnOnce(&Path) -> Result<T, SecretsError>,
@@ -79,7 +239,241 @@ pub(crate) fn with_write_lock<T>(
let path = parent.canonicalize()?.join(filename);
let mut lock_name = path.as_os_str().to_os_string();
lock_name.push(".lock");
let mut lock = fd_lock::RwLock::new(open_private(Path::new(&lock_name), true)?);
let _guard = lock.write()?;
operation(&path)
let mut lock =
fd_lock::RwLock::new(open_private(Path::new(&lock_name), true, StoreFile::Lock)?);
match lock.write() {
Ok(_guard) => operation(&path),
// HarmonyOS hmdfs refuses `flock` and `fcntl(F_SETLK)` with EPERM for
// every file, so the cross-process guard cannot be taken there. Fall
// back to serialising this process's own writers and say so: refusing
// outright would leave no way to store a credential on that mount at
// all. The tradeoff is real and narrow — two Codewhale processes whose
// snapshots of `secrets.json` overlap can lose one of the two writes,
// which needs both to rewrite the same store in the same instant.
Err(error) if advisory_locking_unavailable(&error) => {
tracing::warn!(
"{} cannot take a file lock ({error}); serialising secret-store writes \
within this process only",
lock_name.to_string_lossy()
);
let _guard = in_process_write_guard();
operation(&path)
}
Err(error) => Err(error.into()),
}
}
/// Whether a failed advisory lock means the filesystem has none at all, as
/// opposed to a lock that is genuinely held by someone else.
///
/// `EWOULDBLOCK` is deliberately absent: a held lock must still wait or fail
/// rather than fall back. The rest are the mount saying "no locking here".
#[cfg(unix)]
fn advisory_locking_unavailable(error: &std::io::Error) -> bool {
matches!(
error.raw_os_error(),
Some(libc::EPERM)
| Some(libc::EACCES)
| Some(libc::ENOTSUP)
| Some(libc::EINVAL)
| Some(libc::ENOSYS)
)
}
/// Windows always offers `LockFileEx` through `fd-lock`, so a failed write
/// lock there is genuine contention, never an absent advisory lock.
#[cfg(not(unix))]
fn advisory_locking_unavailable(_error: &std::io::Error) -> bool {
false
}
/// Serialises secret-store writers inside this process when the filesystem
/// offers no advisory lock. Coarse on purpose: store writes are rare and short.
fn in_process_write_guard() -> std::sync::MutexGuard<'static, ()> {
static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
WRITE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
/// Put a store file on disk with exactly `mode`, the way a user running
/// `chmod` on an existing `secrets.json` leaves it.
fn store_at(dir: &Path, mode: u32) -> PathBuf {
let path = dir.join("secrets.json");
fs::write(&path, "{\"entries\":{}}").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
mode,
"the test filesystem did not store {mode:o} verbatim"
);
path
}
fn names_in(dir: &Path) -> Vec<String> {
fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect()
}
#[test]
fn owner_only_store_opens() {
let tmp = tempfile::tempdir().unwrap();
let path = store_at(tmp.path(), 0o600);
open_private(&path, false, StoreFile::Content).expect("an 0600 store must open");
assert_eq!(
names_in(tmp.path()),
vec!["secrets.json".to_string()],
"an 0600 store must be accepted without writing a probe file"
);
}
#[test]
fn store_created_on_first_write_is_owner_only() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
open_private(&path, true, StoreFile::Content).expect("create must succeed");
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600,
"a freshly created store must be owner-only"
);
}
/// Every wider mode on a filesystem that stores mode bits is a real
/// exposure and must stay refused. The probe runs here for real, against
/// this directory, and has to answer "this mount stores modes".
#[test]
fn wider_store_is_refused_with_its_observed_mode() {
for mode in [0o660, 0o640, 0o644, 0o666, 0o606] {
let tmp = tempfile::tempdir().unwrap();
let path = store_at(tmp.path(), mode);
let err = open_private(&path, false, StoreFile::Content).unwrap_err();
match err {
SecretsError::InsecurePermissions {
path: seen,
mode: seen_mode,
} => {
assert_eq!(seen, path);
assert_eq!(seen_mode, mode);
}
other => panic!("mode {mode:o} must be refused; got: {other}"),
}
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
mode,
"a refused store is left exactly as it was found"
);
}
}
/// The lock file carries no secret, so a wide mode on it is hygiene rather
/// than evidence, and the "narrow what can be narrowed" rule applies: the
/// store opens and the file ends up `0600`. This is the case a user hits
/// after restoring a backup, and the one whose refusal read as "your secret
/// backend is broken".
#[test]
fn wide_lock_file_is_repaired_in_place() {
let tmp = tempfile::tempdir().unwrap();
let lock = tmp.path().join("secrets.json.lock");
fs::write(&lock, b"").unwrap();
fs::set_permissions(&lock, fs::Permissions::from_mode(0o660)).unwrap();
open_private(&lock, false, StoreFile::Lock)
.expect("a wide lock must be repaired, not refused");
assert_eq!(
fs::metadata(&lock).unwrap().permissions().mode() & 0o777,
0o600,
"the repair must actually be applied"
);
assert_eq!(
names_in(tmp.path()),
vec!["secrets.json.lock".to_string()],
"a repair must not need the probe"
);
}
/// The classification is the whole decision, and the cases that matter need
/// a mount that refuses `chmod` or accepts it without applying it, which no
/// portable
/// filesystem produces. They are pinned here instead.
#[test]
fn store_mode_classification_covers_every_outcome() {
// The mount stored the narrowing we asked for.
assert_eq!(
classify_store_mode(0o660, Some(0o600), false),
StoreMode::Narrowed
);
// hmdfs: the call is accepted and the mount keeps its own mode.
assert_eq!(
classify_store_mode(0o660, Some(0o660), true),
StoreMode::Unavoidable { mode: 0o660 }
);
// hmdfs/POSIX-less mount: the call is refused.
assert_eq!(
classify_store_mode(0o660, None, true),
StoreMode::Unavoidable { mode: 0o660 }
);
// A mode-storing mount that accepted the call and ignored it is a real
// exposure, and the observed mode is what the caller is told about.
assert_eq!(
classify_store_mode(0o660, Some(0o660), false),
StoreMode::Exposed { mode: 0o660 }
);
assert_eq!(
classify_store_mode(0o660, None, false),
StoreMode::Exposed { mode: 0o660 }
);
// A successful narrowing to something still wider is not a repair.
assert_eq!(
classify_store_mode(0o666, Some(0o660), false),
StoreMode::Exposed { mode: 0o660 }
);
}
#[test]
fn probe_reports_a_mode_storing_filesystem() {
let tmp = tempfile::tempdir().unwrap();
assert!(
!mount_ignores_unix_modes(&tmp.path().join("secrets.json")),
"a local filesystem stores the 0600 a file is created with"
);
}
#[test]
fn probe_leaves_nothing_behind() {
let tmp = tempfile::tempdir().unwrap();
let before = names_in(tmp.path());
assert!(!mount_ignores_unix_modes(&tmp.path().join("secrets.json")));
assert_eq!(names_in(tmp.path()), before);
}
#[test]
fn probe_answers_false_without_a_parent_directory() {
assert!(!mount_ignores_unix_modes(Path::new("secrets.json")));
}
#[test]
fn a_held_lock_is_not_mistaken_for_a_missing_one() {
// EWOULDBLOCK is what a lock held by another process reports; treating
// it as "no locking on this mount" would silently drop serialisation.
assert!(!advisory_locking_unavailable(
&std::io::Error::from_raw_os_error(libc::EWOULDBLOCK)
));
assert!(!advisory_locking_unavailable(
&std::io::Error::from_raw_os_error(libc::EINTR)
));
for errno in [libc::EPERM, libc::EACCES, libc::ENOTSUP, libc::EINVAL] {
assert!(
advisory_locking_unavailable(&std::io::Error::from_raw_os_error(errno)),
"errno {errno} means the filesystem has no advisory locks"
);
}
}
}
+13 -14
View File
@@ -623,13 +623,14 @@ impl FileKeyringStore {
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
use std::io::Read as _;
let mut file = match file_lock::open_private(&self.path, false) {
Ok(file) => file,
Err(SecretsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(FileSecretsBlob::default());
}
Err(error) => return Err(error),
};
let mut file =
match file_lock::open_private(&self.path, false, file_lock::StoreFile::Content) {
Ok(file) => file,
Err(SecretsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(FileSecretsBlob::default());
}
Err(error) => return Err(error),
};
let mut raw = String::new();
file.read_to_string(&mut raw)?;
if raw.trim().is_empty() {
@@ -760,14 +761,12 @@ fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsErro
tmp.write_all(body).map_err(SecretsError::Io)?;
tmp.flush().map_err(SecretsError::Io)?;
tmp.as_file().sync_all().map_err(SecretsError::Io)?;
// The temp file is created at 0o600, so this is not about narrowing it: a
// mount can apply a mode of its own to a new file, and publishing contents
// under one is exactly what the store's own gate exists to prevent. The
// same rule that admits the store admits the file about to replace it.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
tmp.as_file()
.set_permissions(perms)
.map_err(SecretsError::Io)?;
}
file_lock::require_private_mode(tmp.as_file(), path, file_lock::StoreFile::Content)?;
tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
Ok(())
}
+5
View File
@@ -1546,6 +1546,11 @@ mod tests {
None,
)
.await?;
// ACP publications are discovered through this socket, so an
// unbound one is a failure here rather than a degraded start.
let daemon = daemon.ok_or_else(|| {
anyhow!("this filesystem cannot hold the private runtime endpoint directory")
})?;
let shutdown = daemon.shutdown_handle();
let owner = tokio::spawn(daemon.serve());
let _abort = AbortOnDrop(owner.abort_handle());
@@ -110,7 +110,7 @@ pub(in crate::commands) fn apply_plan(
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(ticket);
std::fs::read(&copy_source).is_ok_and(|bytes| {
codewhale_config::private_directory::PrivateDirectory::open(
codewhale_config::private_directory::PrivateDirectory::open_trusting_mount(
&copy_home,
)
.and_then(|directory| {
@@ -131,7 +131,7 @@ pub(in crate::commands) fn apply_plan(
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(ticket);
std::fs::read(&copy_source).is_ok_and(|bytes| {
codewhale_config::private_directory::PrivateDirectory::open(
codewhale_config::private_directory::PrivateDirectory::open_trusting_mount(
&copy_home,
)
.and_then(|directory| {
+7 -5
View File
@@ -412,11 +412,13 @@ impl RuntimeEventJournal {
let legacy_session_tag = runtime_journal_session_tag(session_id);
std::fs::create_dir_all(dir).map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
}
// Journal files are written owner-only; the directory narrows to 0o700
// where the filesystem stores modes. A mount that assigns modes itself
// (HarmonyOS hmdfs) refuses the call, and `tighten_mode` tolerates only
// that case — refusing here would disable the recoverable-turn journal
// on a platform where no directory can ever satisfy the chmod.
codewhale_config::private_directory::tighten_mode(dir, 0o700)
.map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
let legacy_path = dir.join(format!("journal_{legacy_session_tag}.json"));
let active_index_path = dir.join(format!("active_classic_{legacy_session_tag}.json"));
let classic_session_lock =
+258 -34
View File
@@ -404,6 +404,15 @@ impl RuntimeWorkspaceScopes {
})
.map(|label| label.replace(['\n', '='], " "));
let store_root = owner.owner.session_store_binding().data_dir;
// Three outcomes, two of them refusals. A second driver is refused
// with its holder's facts. A filesystem that cannot *carry* the lease
// is not a second driver, and failing the whole engine over it turns
// a mount limitation into "Engine exited before becoming ready". Run
// there, and say what was lost every time the scope is admitted
// rather than assuming exclusivity still holds. Admission relaxes
// only the ownership proof: the lock is a separate capability and is
// still taken, so this branch is only reached when the filesystem
// refuses advisory locks themselves.
let drive_lease =
codewhale_runtime::workspace_lease::SharedWorkspaceDriveLease::acquire(
&lexical,
@@ -416,10 +425,21 @@ impl RuntimeWorkspaceScopes {
lexical.display()
)
})?;
let Some(drive_lease) = drive_lease else {
let refusal = WorkspaceDriveLeaseRefusal::named_holder(&lexical)
.unwrap_or_else(WorkspaceDriveLeaseRefusal::unreadable);
return Err(anyhow::Error::new(refusal));
let drive_lease = match drive_lease {
codewhale_runtime::workspace_lease::LeaseAdmission::Held(lease) => Some(lease),
codewhale_runtime::workspace_lease::LeaseAdmission::Contended => {
let refusal = WorkspaceDriveLeaseRefusal::named_holder(&lexical)
.unwrap_or_else(WorkspaceDriveLeaseRefusal::unreadable);
return Err(anyhow::Error::new(refusal));
}
codewhale_runtime::workspace_lease::LeaseAdmission::Unsupported(unavailable) => {
tracing::warn!(
workspace = %lexical.display(),
"{unavailable} — running without workspace drive exclusivity; \
another client may write this tree at the same time"
);
None
}
};
owner
.workers
@@ -435,7 +455,7 @@ impl RuntimeWorkspaceScopes {
lsp: std::sync::OnceLock::new(),
owner: owner.owner.clone(),
cleanup_runtime: owner.cleanup_runtime.clone(),
_drive_lease: Some(drive_lease),
_drive_lease: drive_lease,
});
scopes.insert(lexical, scope.clone());
Ok(scope)
@@ -479,6 +499,219 @@ impl RuntimeServerShutdown {
}
}
/// Run the long-lived components of `serve --http` until one of them ends.
///
/// Any ending stops the others: a Runtime API server that stops must stop the
/// control endpoint, and an endpoint that stops must stop the server. The
/// endpoint is optional — a filesystem that cannot hold its private directory
/// leaves it out — and its absence has to behave like an *already finished*
/// stop. Awaiting a future that never completes would hang a clean shutdown
/// instead of ending it, which is why the absence is carried as a flag and the
/// select arm for a missing endpoint can never fire.
///
/// `stop` is the endpoint's own way of asking it to stop, so this stays
/// independent of how the socket is represented.
async fn supervise_frontends<S, T, F>(
serve: S,
stdio: T,
endpoint: Option<(
tokio::task::JoinHandle<Result<(), codewhale_app_server::daemon_socket::DaemonSocketError>>,
F,
)>,
shutdown: &RuntimeServerShutdown,
) -> (Result<()>, Result<()>)
where
S: std::future::Future<Output = std::io::Result<()>>,
T: std::future::Future<Output = Result<()>>,
F: FnOnce(),
{
let (has_endpoint, mut stop, task) = match endpoint {
Some((task, stop)) => (true, Some(stop), Some(task)),
None => (false, None, None),
};
let mut endpoint_stopped = Box::pin(async move {
match task {
Some(task) => task
.await
.context("owner control frontend task failed")
.and_then(|result| result.map_err(Into::into)),
// Never ready: an arm for a component that does not exist must not
// fire. The joins below check `has_endpoint` instead.
None => std::future::pending::<Result<()>>().await,
}
});
tokio::pin!(serve);
tokio::pin!(stdio);
tokio::select! {
result = &mut serve => {
if let Some(stop) = stop.take() { stop(); }
let owner = if has_endpoint { endpoint_stopped.await } else { Ok(()) };
(result.map_err(|e| anyhow!("Runtime API server error: {e}")), owner)
}
result = &mut stdio => {
shutdown.requested.cancel();
let served = serve.await.map_err(|error| anyhow!("Runtime API server error: {error}"));
if let Some(stop) = stop.take() { stop(); }
let owner = if has_endpoint { endpoint_stopped.await } else { Ok(()) };
(result.and(served), owner)
}
owner = &mut endpoint_stopped => {
shutdown.requested.cancel();
let result = serve.await.map_err(|e| anyhow!("Runtime API server error: {e}"));
(result, owner.and_then(|()| Err(anyhow!("owner control frontend stopped before its Runtime host"))))
}
}
}
#[cfg(test)]
mod supervise_frontends_tests {
use super::{RuntimeServerShutdown, supervise_frontends};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
fn shutdown_handle() -> RuntimeServerShutdown {
RuntimeServerShutdown {
requested: tokio_util::sync::CancellationToken::new(),
stopped: tokio_util::sync::CancellationToken::new(),
}
}
/// A stand-in for `serve_runtime_api` that drains when asked, the way the
/// real one does. A fake that ignored the token would hang for reasons that
/// have nothing to do with the supervision being tested.
fn draining_server(
shutdown: &RuntimeServerShutdown,
) -> impl std::future::Future<Output = std::io::Result<()>> {
let requested = shutdown.requested.clone();
async move {
requested.cancelled().await;
Ok(())
}
}
/// The degraded start: HTTP only. Stopping the server must end the whole
/// supervision instead of waiting for an endpoint task that does not exist.
#[tokio::test]
async fn a_server_without_an_endpoint_stops_instead_of_waiting_forever() {
let shutdown = shutdown_handle();
let stdio = std::future::pending::<anyhow::Result<()>>();
let (served, owner) = tokio::time::timeout(
Duration::from_secs(5),
supervise_frontends(
async { Ok(()) },
stdio,
None::<(
tokio::task::JoinHandle<
Result<(), codewhale_app_server::daemon_socket::DaemonSocketError>,
>,
fn(),
)>,
&shutdown,
),
)
.await
.expect("a missing endpoint must not hold shutdown open");
served.expect("the server result is passed through");
owner.expect("a stop with no endpoint to wait for is a completed stop");
}
/// An endpoint that stops on its own is an error: nothing is serving the
/// Runtime host any more, so pretending the shutdown was clean would hide it.
#[tokio::test]
async fn an_endpoint_that_stops_first_is_reported() {
let shutdown = shutdown_handle();
let endpoint = Some((tokio::spawn(async { Ok(()) }), || {}));
let stdio = std::future::pending::<anyhow::Result<()>>();
let (_served, owner) = tokio::time::timeout(
Duration::from_secs(5),
supervise_frontends(draining_server(&shutdown), stdio, endpoint, &shutdown),
)
.await
.expect("an endpoint that ends must end the supervision");
let error = owner.expect_err("a stopped endpoint is not a clean shutdown");
assert!(
error
.to_string()
.contains("stopped before its Runtime host"),
"{error}"
);
}
/// The normal start: stopping the server asks the endpoint to stop and then
/// waits for it, so a second client is not left with a dead socket.
#[tokio::test]
async fn stopping_the_server_stops_and_joins_the_endpoint() {
let shutdown = shutdown_handle();
let asked = Arc::new(AtomicBool::new(false));
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let endpoint = Some((
tokio::spawn(async move {
let _ = stop_rx.await;
Ok(())
}),
{
let asked = asked.clone();
move || {
asked.store(true, Ordering::SeqCst);
let _ = stop_tx.send(());
}
},
));
let stdio = std::future::pending::<anyhow::Result<()>>();
let (served, owner) = tokio::time::timeout(
Duration::from_secs(5),
supervise_frontends(async { Ok(()) }, stdio, endpoint, &shutdown),
)
.await
.expect("the endpoint must be asked to stop and then joined");
served.expect("the server result is passed through");
owner.expect("the endpoint stopped when asked");
assert!(
asked.load(Ordering::SeqCst),
"the endpoint was never asked to stop"
);
}
/// A stdio frontend ending stops both the server and the endpoint.
#[tokio::test]
async fn a_finished_stdio_frontend_stops_the_other_components() {
let shutdown = shutdown_handle();
let asked = Arc::new(AtomicBool::new(false));
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let endpoint = Some((
tokio::spawn(async move {
let _ = stop_rx.await;
Ok(())
}),
{
let asked = asked.clone();
move || {
asked.store(true, Ordering::SeqCst);
let _ = stop_tx.send(());
}
},
));
let (served, owner) = tokio::time::timeout(
Duration::from_secs(5),
supervise_frontends(
draining_server(&shutdown),
async { Ok(()) },
endpoint,
&shutdown,
),
)
.await
.expect("stdio ending must end the supervision");
served.expect("the drained server has no error");
owner.expect("the endpoint stopped when asked");
assert!(
asked.load(Ordering::SeqCst),
"the endpoint was never asked to stop"
);
}
}
/// Serve `app` until `shutdown` is requested, then drain gracefully so the
/// final frames of open streams reach their clients before connections close.
async fn serve_runtime_api(
@@ -2084,7 +2317,7 @@ pub async fn run_http_server(
compat_stream_test_hook: None,
};
#[cfg(any(unix, windows))]
let (owner_frontend, control_state) = bind_captured_runtime_frontends(
let (owner_endpoint, control_state) = bind_captured_runtime_frontends(
&state,
control_socket.clone(),
match options.control_frontend.as_ref() {
@@ -2173,8 +2406,6 @@ pub async fn run_http_server(
let signal_registration = SignalShutdownRegistration::register(&shutdown);
#[cfg(any(unix, windows))]
let (serve_result, owner_result) = {
let owner_handle = owner_frontend.shutdown_handle();
let mut owner_task = tokio::spawn(owner_frontend.serve());
let stdio = async {
if acp_selected {
codewhale_app_server::run_owned_acp(control_state).await
@@ -2184,31 +2415,24 @@ pub async fn run_http_server(
std::future::pending::<Result<()>>().await
}
};
tokio::pin!(stdio);
let serve = serve_runtime_api(listener, app, shutdown.clone());
tokio::pin!(serve);
tokio::select! {
result = &mut serve => {
owner_handle.trigger();
let owner = owner_task.await.context("owner control frontend task failed")
.and_then(|result| result.map_err(Into::into));
(result.map_err(|e| anyhow!("Runtime API server error: {e}")), owner)
}
result = &mut stdio => {
shutdown.requested.cancel();
let served = serve.await.map_err(|error|anyhow!("Runtime API server error: {error}"));
owner_handle.trigger();
let owner=owner_task.await.context("owner control frontend task failed").and_then(|result|result.map_err(Into::into));
(result.and(served),owner)
}
owner = &mut owner_task => {
shutdown.requested.cancel();
let result = serve.await.map_err(|e| anyhow!("Runtime API server error: {e}"));
let owner = owner.context("owner control frontend task failed")
.and_then(|result| result.map_err(Into::into));
(result, owner.and_then(|()| Err(anyhow!("owner control frontend stopped before its Runtime host"))))
}
}
// `None` when this filesystem cannot hold the private endpoint
// directory. The Runtime API over HTTP is what clients connect to, so it
// runs either way; the endpoint socket is how a second client discovers
// this owner, and `bind_captured_owner` has already said what was lost.
let endpoint = owner_endpoint.map(|endpoint| {
let stop = {
let handle = endpoint.shutdown_handle();
move || handle.trigger()
};
(tokio::spawn(endpoint.serve()), stop)
});
supervise_frontends(
serve_runtime_api(listener, app, shutdown.clone()),
stdio,
endpoint,
&shutdown,
)
.await
};
#[cfg(not(any(unix, windows)))]
let serve_result = serve_runtime_api(listener, app, shutdown)
@@ -2330,7 +2554,7 @@ async fn bind_captured_runtime_frontends(
generated_auth: bool,
recovery: Option<Arc<codewhale_app_server::daemon_client::UnavailablePublication>>,
) -> Result<(
codewhale_app_server::daemon_socket::DaemonSocket,
Option<codewhale_app_server::daemon_socket::DaemonSocket>,
codewhale_app_server::AppState,
)> {
let captured_manager = state.runtime_threads.clone();
+3 -1
View File
@@ -1417,7 +1417,9 @@ async fn build_test_server(
.map_err(|error| anyhow::anyhow!(error.message))?;
let (owner, compatibility) =
bind_captured_runtime_frontends(&state, Some(socket), model, 1, false, None).await?;
(Some(owner), Some(compatibility))
// `owner` is already an `Option`: the helper reports an unbound
// endpoint when the filesystem cannot hold one.
(owner, Some(compatibility))
} else {
(None, None)
};
@@ -219,6 +219,7 @@ async fn two_crash_reentries_converge_on_one_owner_and_preserve_saved_draft() ->
Some(recovery),
)
.await?;
let daemon = daemon.expect("a private endpoint directory is available on this host");
let stop = daemon.shutdown_handle();
let serving = tokio::spawn(daemon.serve());
let RuntimeHostAdmission::Attached(client) = loser.await?? else {
+1 -2
View File
@@ -1030,7 +1030,6 @@ mod tests {
#[cfg(unix)]
#[test]
fn workspace_status_does_not_run_the_repository_fsmonitor() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir()?;
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo)?;
@@ -1056,7 +1055,7 @@ mod tests {
&hook,
format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()),
)?;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
codewhale_config::private_directory::tighten_mode(&hook, 0o755)?;
assert!(git(&["config", "core.fsmonitor", &hook.to_string_lossy()]));
let status = collect_workspace_status(&repo);
+3 -2
View File
@@ -1673,9 +1673,10 @@ fn isolated_chat_execution_config(
.map_err(|_| "Runtime Chat could not prepare its isolated Chat context.".to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// A mount that assigns modes itself refuses every `chmod`; the context
// directories are linked only from this session either way.
for path in [&workspace, &context_dir, &skills_dir] {
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| {
codewhale_config::private_directory::tighten_mode(path, 0o700).map_err(|_| {
"Runtime Chat could not protect its isolated Chat context.".to_string()
})?;
}
+8 -6
View File
@@ -190,13 +190,14 @@ pub(crate) fn open_private_lock_file(path: &Path) -> io::Result<fs::File> {
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::os::unix::fs::OpenOptionsExt;
options
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
let file = options.open(path)?;
validate_private_regular_file(&file, path)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
codewhale_config::private_directory::tighten_mode_of(&file, path, 0o600)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(file)
}
#[cfg(windows)]
@@ -2021,12 +2022,13 @@ impl SessionManager {
let paths = self.late_usage_paths(session_id)?;
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
OpenOptions::new()
use std::os::unix::fs::OpenOptionsExt as _;
let handle = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&dir)?
.set_permissions(fs::Permissions::from_mode(0o700))?;
.open(&dir)?;
codewhale_config::private_directory::tighten_mode_of(&handle, &dir, 0o700)
.map_err(|error| std::io::Error::other(error.to_string()))?;
}
Ok(paths)
}
+64 -8
View File
@@ -486,7 +486,7 @@ impl AnchoredDirectory {
fn open(path: &Path, create: bool) -> io::Result<Self> {
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::fs::MetadataExt;
use std::path::Component;
// SAFETY: constant C string; successful descriptor immediately owned.
let fd = unsafe {
@@ -527,13 +527,33 @@ impl AnchoredDirectory {
// SAFETY: fd is freshly owned.
current = unsafe { File::from_raw_fd(fd) };
}
let observed = current.metadata()?;
// SAFETY: geteuid has no preconditions.
if current.metadata()?.uid() != unsafe { libc::geteuid() } {
let foreign_owner = observed.uid() != unsafe { libc::geteuid() };
let wide = observed.mode() & 0o077 != 0;
// A mount that reports an owner and mode of its own (HarmonyOS hmdfs)
// cannot prove either, for this directory or any other. The no-follow
// walk above is the guard that still holds there. The filesystem is only
// asked after a field has failed, through the handle just retained, and
// the answer is cached per directory.
let synthesised = (foreign_owner || wide)
&& matches!(
codewhale_config::private_directory::mount_footing_of(&current),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
);
if foreign_owner && !synthesised {
return Err(io::ErrorKind::PermissionDenied.into());
}
if create {
current.set_permissions(std::fs::Permissions::from_mode(0o700))?;
} else if current.metadata()?.mode() & 0o077 != 0 {
// Writing is a repair path, exactly as it was before: a report
// directory that a backup restore or `cp -r` left wider than 0700 is
// narrowed and then used. Refusing here instead would make every
// save fail until someone ran `chmod` by hand.
codewhale_config::private_directory::tighten_mode_of(&current, path, 0o700)
.map_err(|error| io::Error::other(error.to_string()))?;
} else if wide && !synthesised {
// Reading cannot repair: a wide mode means these reports may already
// have been read or replaced, so they are not ours to trust.
return Err(io::ErrorKind::PermissionDenied.into());
}
Ok(Self(current))
@@ -562,11 +582,17 @@ impl AnchoredDirectory {
use std::os::unix::fs::MetadataExt;
let file = self.file(name, libc::O_RDONLY)?;
let metadata = file.metadata()?;
// A mount that reports an owner and mode of its own cannot prove
// either; the link count is a stored property, so it always holds. The
// mount is asked only when the fields disagree, and once per directory.
// SAFETY: geteuid has no preconditions. Reject hardlinks and FIFOs.
if metadata.uid() != unsafe { libc::geteuid() }
|| metadata.nlink() != 1
|| metadata.mode() & 0o077 != 0
{
let public = metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0;
let ownership_unproven = public
&& !matches!(
codewhale_config::private_directory::mount_footing_of(&self.0),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
);
if ownership_unproven || metadata.nlink() != 1 {
return Err(io::ErrorKind::PermissionDenied.into());
}
bounded_read(file)
@@ -665,6 +691,36 @@ mod tests {
}
}
/// Writing repairs a directory someone widened; reading refuses it. That
/// asymmetry is what this file did before the ownership guard was relaxed
/// for mounts that cannot express it, and it is easy to lose: a report
/// directory left at 0755 by a restore must not make every save fail until
/// someone runs `chmod` by hand.
#[cfg(unix)]
#[test]
fn a_widened_report_directory_is_repaired_on_write_and_refused_on_read() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
// The walk opens every component with O_NOFOLLOW, so the fixture needs a
// path with no symlinked component (`/var` is one on macOS).
let dir = tmp.path().canonicalize().unwrap().join("reports");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
AnchoredDirectory::open(&dir, false).is_err(),
"reading a directory anyone can modify is refused"
);
AnchoredDirectory::open(&dir, true).expect("writing repairs it and continues");
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
0o700,
"the repair must actually be applied, not merely attempted"
);
AnchoredDirectory::open(&dir, false).expect("the repaired directory reads");
}
fn fields() -> ReportFields {
serde_json::from_value(json!({"title":"Tool result was lost", "expected":"The agent receives the result", "actual":"No result reached the agent", "impact":"The task needed a retry", "steps":["Request the tool", "Wait for completion"], "observed":["The tool completed but no result arrived"], "inferred":["A Runtime event may have been lost"], "related_issues":[123]})).unwrap()
}
+3 -4
View File
@@ -12042,8 +12042,7 @@ fn prepare_subagent_transcript_parent(state_root: &Path, path: &Path) -> Result<
reject_state_path_symlinks(state_root, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
codewhale_config::private_directory::tighten_mode(parent, 0o700)?;
}
Ok(())
}
@@ -12075,7 +12074,7 @@ fn append_private_subagent_transcript(
#[cfg(unix)]
fn open_private_subagent_transcript(path: &Path, append: bool) -> Result<fs::File> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::os::unix::fs::OpenOptionsExt;
let mut options = fs::OpenOptions::new();
options
@@ -12086,7 +12085,7 @@ fn open_private_subagent_transcript(path: &Path, append: bool) -> Result<fs::Fil
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600);
let file = options.open(path)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
codewhale_config::private_directory::tighten_mode_of(&file, path, 0o600)?;
Ok(file)
}
+3 -2
View File
@@ -82,7 +82,7 @@ use std::time::{Duration, Instant};
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::fs::FileTypeExt;
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(unix)]
@@ -596,7 +596,8 @@ pub(crate) fn bind_control_socket(
let listener = UnixListener::bind(&path)?;
let identity = socket_file_identity(&path);
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
codewhale_config::private_directory::tighten_mode(&path, 0o600)
.map_err(|error| std::io::Error::other(error.to_string()))?;
listener.set_nonblocking(true)?;
let stop = Arc::new(AtomicBool::new(false));
+15 -3
View File
@@ -306,12 +306,24 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> {
std::fs::create_dir_all(&root)?;
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::symlink_metadata(&root)?;
if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
if !metadata.is_dir() {
anyhow::bail!("Pet directory must be a directory and cannot be a link");
}
// A mount that reports an owner of its own (HarmonyOS hmdfs) makes the
// uid comparison meaningless; the link refusal above still holds.
let ownership_proven = metadata.uid() == unsafe { libc::geteuid() }
|| matches!(
codewhale_config::private_directory::mount_footing(&root),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
);
if !ownership_proven {
anyhow::bail!("Pet directory must belong to this user and cannot be a link");
}
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
// `tighten_mode` keeps the refusal for a filesystem that stores modes
// and excuses only one that assigns its own.
codewhale_config::private_directory::tighten_mode(&root, 0o700)?;
}
let lock_path = WorkspaceFile::open_resolved_root(&root, Path::new("owner.lock"), true)?;
let original = lock_path.open_update(true, false)?;
+38 -8
View File
@@ -342,7 +342,16 @@ impl WorkspaceFile {
}
if let Some(mode) = mode {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(mode))?;
// The file was just created with the caller's access bits, so
// this only matters on a mount that applied its own. There it
// answers EPERM for every `chmod`, and the delivery still has
// to land: the handle, the single link and the publication
// below are what this writer guarantees.
if let Err(error) = file.set_permissions(std::fs::Permissions::from_mode(mode))
&& !mount_cannot_extend_guard(&self.directory)
{
return Err(error);
}
}
file.write_all(bytes)?;
file.sync_all()?;
@@ -416,11 +425,16 @@ impl WorkspaceFile {
let owner = codewhale_config::private_directory::PrivateDirectory::current_user_id();
let parent = self.directory.metadata()?;
let entry = file.metadata()?;
if parent.uid() != owner
let public = parent.uid() != owner
|| parent.mode() & 0o022 != 0
|| entry.uid() != owner
|| entry.mode() & 0o022 != 0
{
|| entry.mode() & 0o022 != 0;
// A mount that reports an owner and mode of its own (HarmonyOS hmdfs
// reports the logged-in user's uid and a group-writable mode for every
// object) cannot satisfy any of those fields. Only a disagreement asks
// the mount, so a filesystem that stores them never pays for the probe
// and a delivery that already looks private is never delayed.
if public && !mount_cannot_extend_guard(&self.directory) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"delivery file and retained parent must be owned by the current user and not writable by group or others",
@@ -455,10 +469,9 @@ impl WorkspaceFile {
use std::os::fd::AsRawFd;
use std::os::unix::fs::MetadataExt;
let parent = self.directory.metadata()?;
if parent.mode() & 0o077 != 0
|| parent.uid() != unsafe { libc::geteuid() }
|| !same_file(file, &self.identity_probe()?)?
{
let public = parent.mode() & 0o077 != 0 || parent.uid() != unsafe { libc::geteuid() };
let unverified = public && !mount_cannot_extend_guard(&self.directory);
if unverified || !same_file(file, &self.identity_probe()?)? {
return Err(invalid_path());
}
if unsafe { libc::unlinkat(self.directory.as_raw_fd(), self.filename.as_ptr(), 0) } != 0 {
@@ -947,6 +960,23 @@ pub fn rename_windows_opened(
}
}
/// Whether `directory`'s filesystem reports an owner and mode of its own for
/// objects created in it, so neither can serve as evidence about this file.
///
/// The question is the one [`codewhale_config::private_directory::private_footing`]
/// answers, asked through the directory handle the caller already holds: no
/// path is involved, so nothing outside this directory can be reached, and the
/// answer is cached per directory so a write path pays for it at most once.
/// Only a directory this process cannot write to answers `false`, which keeps
/// the strict error above — that is a real problem, and not this one.
#[cfg(unix)]
fn mount_cannot_extend_guard(directory: &std::fs::File) -> bool {
matches!(
codewhale_config::private_directory::mount_footing_of(directory),
codewhale_config::private_directory::PrivateFooting::Synthesised { .. }
)
}
#[cfg(all(test, unix))]
mod unix_publication_tests {
use super::*;
+22
View File
@@ -17,6 +17,28 @@ pub fn is_contention(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::WouldBlock || matches!(error.raw_os_error(), Some(32 | 33))
}
/// Whether a failed advisory lock means the filesystem has none at all, as
/// opposed to a lock that is genuinely held by someone else.
///
/// `EWOULDBLOCK` is deliberately absent: a held lock must still wait or fail
/// rather than fall back. The rest are the mount saying "no locking here" —
/// HarmonyOS hmdfs answers `EPERM` for `flock` and `fcntl(F_SETLK)` on every
/// file, and FUSE and some network mounts answer the same way.
///
/// A caller that degrades instead of failing must still refuse when
/// [`is_contention`] is true; the two predicates are disjoint by construction.
#[cfg(unix)]
pub fn advisory_locking_unavailable(error: &std::io::Error) -> bool {
matches!(
error.raw_os_error(),
Some(libc::EPERM)
| Some(libc::EACCES)
| Some(libc::ENOTSUP)
| Some(libc::EINVAL)
| Some(libc::ENOSYS)
)
}
pub fn try_lock_exclusive(file: &File) -> io::Result<()> {
#[cfg(unix)]
{
+6 -3
View File
@@ -607,9 +607,12 @@ fn write_atomic_scoped(
// executable bits of an existing workspace file before persisting.
#[cfg(unix)]
if let Some(mode) = existing_workspace_mode {
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(fs::Permissions::from_mode(mode))?;
// Restoring the access bits of the file this replaces. A mount that
// assigns modes itself (HarmonyOS hmdfs) returns EPERM for every
// `chmod`; `tighten_mode` tolerates only that, so the write lands
// and a real permission failure is still reported.
codewhale_config::private_directory::tighten_mode_of(tmp.as_file(), tmp.path(), mode)
.map_err(|error| std::io::Error::other(error.to_string()))?;
}
tmp.as_file().sync_all()?;
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env sh
# Report what a HarmonyOS directory does with ownership, mode and advisory
# locks, and say what the workspace drive lease will make of it.
#
# The lease admits its state directory as a private directory, which requires
# current-user ownership and a mode with no group or other bits. A mount that
# synthesises those attributes instead of storing them can never satisfy that
# proof, so `private_directory::private_footing` probes the mount once and the
# directory is admitted with that guard recorded as unverifiable. The lease is
# still taken: exclusivity is the kernel lock, a different capability.
#
# What this script reports is therefore the *proof*, not the lease: a
# synthesised footing means the ownership guard is relaxed and the log says so,
# not that exclusivity is lost. Exclusivity is lost only where `flock` itself is
# refused, which the engine's own warning names at startup. This script runs the
# same probe by hand, on the directory you name, and then exercises the real
# binary on it.
#
# Run it in the shell whose uid the engine will have: the important comparison
# is `id -u` against the owner the mount reports for a directory that uid just
# created. On a 2in1 the user directory (/storage/Users/currentUser, hmdfs)
# reports the logged-in user's uid for every object, so an app whose uid differs
# never sees a match there. The app sandbox (/data/storage/el2/base, reached
# inside the app as $HOME-like Context paths) does report the app's own uid.
#
# # inside the CodeArts integrated terminal, so uid is the app's:
# sh ohos-lease-footing-probe.sh "$HOME" /data/storage/el2/base/files
#
# # from hdc shell, to compare what the debug shell sees:
# hdc shell "sh /data/local/tmp/ohos-lease-footing-probe.sh $HOME"
#
# Exit status:
# 0 every root named can prove ownership (the full private-directory guard)
# 4 at least one root cannot (the guard is relaxed there, and the log says so)
# 2 bad usage
set -eu
usage() {
cat >&2 <<'USAGE'
usage: ohos-lease-footing-probe.sh <directory>...
<directory> a directory the engine's state would live in. Nothing outside it
is touched; the probe creates and removes one 0o700 directory.
environment:
CODEWHALE_BIN the engine binary. When set, the probe also runs it against
the first directory so the lease outcome is observed rather
than predicted.
USAGE
exit 2
}
[ $# -ge 1 ] || usage
uid=$(id -u)
mode_of() {
# Toybox stat prints the octal mode for -c '%a'; BSD stat uses -f.
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || echo '?'
}
uid_of() {
stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" 2>/dev/null || echo '?'
}
echo "process uid: $uid"
echo "user: $(id 2>/dev/null || true)"
echo
degraded=0
for dir in "$@"; do
dir=${dir%/}
if [ ! -d "$dir" ]; then
echo "== $dir"
echo " missing; skipping (create it first, or point the probe at an ancestor)"
echo
continue
fi
probe=$dir/.codewhale-owner-probe-$$
rm -rf "$probe"
if ! mkdir -m 700 "$probe" 2>/dev/null; then
# Toybox mkdir has no -m on some builds.
mkdir "$probe" 2>/dev/null || {
echo "== $dir"
echo " cannot create a directory here; the engine will report a refusal,"
echo " not a mount limitation, for this root"
echo
degraded=1
continue
}
chmod 700 "$probe" 2>/dev/null || true
fi
owner=$(uid_of "$probe")
mode=$(mode_of "$probe")
rm -rf "$probe"
echo "== $dir"
echo " created at 0700 -> owner $owner, mode $mode"
owner_ok=no
[ "$owner" = "$uid" ] && owner_ok=yes
mode_ok=no
case $mode in
?0 | ?00) mode_ok=yes ;; # e.g. 700, 0700, 0
esac
if [ "$owner_ok" = yes ] && [ "$mode_ok" = yes ]; then
echo ' verdict: native footing — the ownership guard holds here'
else
echo " verdict: synthesised footing — owner matches: $owner_ok, mode owner-only: $mode_ok"
echo ' Directories here are admitted with the ownership guard'
echo ' recorded as unverifiable: the engine logs it once per'
echo ' directory and relies on the mount'"'"'s access control.'
echo ' The workspace lease is still taken — exclusivity is the'
echo ' kernel lock, which this probe does not measure. If the'
echo ' engine also reports that advisory locks are refused, that'
echo ' is when it runs without exclusivity.'
degraded=1
fi
echo
done
if [ -n "${CODEWHALE_BIN:-}" ]; then
first=${1%/}
echo "== engine check on $first"
if [ ! -x "$CODEWHALE_BIN" ]; then
echo " CODEWHALE_BIN is not executable: $CODEWHALE_BIN"
degraded=1
else
log=$first/.codewhale-lease-probe-$$.log
store=$first/.codewhale-lease-probe-$$.store
echo " version: $("$CODEWHALE_BIN" --version 2>/dev/null | head -1)"
# `serve --http` is the invocation the editor extension and any other
# client use, so it is the one whose lease outcome matters. It is a
# long-running server: bound it with a timeout and read what it said.
set +e
CODEWHALE_HOME="$store" timeout 20 "$CODEWHALE_BIN" --workspace "$first" \
serve --http --host 127.0.0.1 --port 47899 >"$log" 2>&1
status=$?
set -e
if grep -q 'Runtime API listening on' "$log"; then
echo ' ok: the engine started'
else
echo " engine did not reach its listener (exit $status); last lines:"
tail -12 "$log" | sed 's/^/ /'
fi
if grep -q 'cannot provide workspace exclusivity' "$log"; then
echo ' lease: UNHELD — this filesystem refuses advisory locks'
echo ' message:'
grep 'cannot provide workspace exclusivity' "$log" | head -2 | sed 's/^/ /'
elif grep -q 'already driving this workspace' "$log"; then
echo ' lease: refused — another client holds this workspace'
elif grep -q 'Runtime API listening on' "$log"; then
echo ' lease: held (the filesystem provides locks)'
fi
if grep -q 'ownership cannot be verified' "$log"; then
echo ' guard: relaxed here, as the footing probe predicted'
fi
rm -f "$log"
rm -rf "$store"
fi
echo
fi
if [ "$degraded" -eq 0 ]; then
echo 'PASS: every directory named can carry a private lease state root'
exit 0
fi
echo 'RELAXED: at least one directory cannot prove current-user ownership.'
echo 'The engine still runs there and still holds the workspace lease; it logs'
echo 'the relaxed guard once per directory. Move the state to a root that can'
echo 'store ownership (see docs/HarmonyOS.md, "Workspace Exclusivity On The User'
echo 'Directory") if you want the full private-directory guard back.'
exit 4
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env sh
# Verify the file-backed secret store against a real directory, including mounts
# that cannot hold a 0600 mode or an advisory lock.
#
# The unit tests in crates/secrets cover both branches of the mode check, but no
# portable filesystem synthesises mode bits or refuses flock, so a test cannot
# build the branch that matters on HarmonyOS hmdfs. This script covers it by
# driving the real binary against a real directory instead of stubbing one, and
# it says which layer refused when something fails.
#
# Run it against each filesystem you care about and compare:
#
# scripts/ohos-secrets-store-smoke.sh /tmp
# scripts/ohos-secrets-store-smoke.sh "$HOME"
#
# On a HarmonyOS 2in1, the user directory (/storage/Users/currentUser, hmdfs)
# exercises the mode-ignoring and no-advisory-lock fallbacks, and /data (hmfs)
# proves the strict path still holds where mode bits are stored.
#
# Over a device connection, run it inside the device shell:
#
# hdc file send target/aarch64-unknown-linux-ohos/release/codewhale /data/local/tmp/codewhale
# hdc file send scripts/ohos-secrets-store-smoke.sh /data/local/tmp/smoke.sh
# hdc shell "chmod +x /data/local/tmp/codewhale /data/local/tmp/smoke.sh; \
# CODEWHALE_BIN=/data/local/tmp/codewhale sh /data/local/tmp/smoke.sh /data/local/tmp"
#
# Exit status:
# 0 every check passed
# 1 the secret store failed
# 2 bad usage
# 3 a different layer refused before the secret store was reached
set -eu
usage() {
cat >&2 <<'USAGE'
usage: scripts/ohos-secrets-store-smoke.sh <directory> [binary]
<directory> an existing, writable directory. The script creates and removes
its own subdirectory there; nothing else is touched.
[binary] the binary to test. Defaults to $CODEWHALE_BIN, then
target/debug/codewhale, then target/release/codewhale.
USAGE
exit 2
}
[ $# -ge 1 ] || usage
dir=${1%/}
bin=${2:-${CODEWHALE_BIN:-}}
if [ -z "$bin" ]; then
for candidate in target/debug/codewhale target/release/codewhale; do
if [ -x "$candidate" ]; then
bin=$candidate
break
fi
done
fi
fail() {
echo "FAIL: $*" >&2
exit 1
}
mode_of() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || echo '?'
}
[ -n "$bin" ] || fail "no binary found; pass one or set CODEWHALE_BIN"
[ -x "$bin" ] || fail "binary is not executable: $bin"
[ -d "$dir" ] || fail "not a directory: $dir"
home=$dir/codewhale-secrets-smoke.$$
log=$dir/codewhale-secrets-smoke.$$.log
trap 'rm -rf "$home"; rm -f "$log"' EXIT HUP INT TERM
mkdir -p "$home" || fail "cannot create $home"
echo "binary : $bin"
echo "version: $("$bin" --version 2>/dev/null | head -1)"
echo "home : $home"
# What this mount does with a mode it is asked to store. Create a file, try to
# narrow it to 0600, and read back what the mount kept. A filesystem that stores
# mode bits lands on 0600; a mount that synthesises them stays wider and either
# refuses the chmod or ignores it.
probe=$home/.mode-probe
: >"$probe" || fail "cannot create a file in $home"
probe_before=$(mode_of "$probe")
chmod 600 "$probe" 2>/dev/null || true
probe_after=$(mode_of "$probe")
rm -f "$probe"
echo "mount : $dir"
echo " file created at $probe_before; after chmod 600 it is $probe_after"
case $probe_after in
600 | 400 | 0)
mount_note='stores mode bits'
echo ' -> stores mode bits; the strict path is expected'
;;
*)
mount_note='synthesises mode bits'
echo ' -> does not store mode bits; the fallback path is expected'
;;
esac
echo
# A key that is obviously not a credential, and never leaves this host.
api_key='[redacted]'
echo '--- 1. auth set writes the key'
if ! printf '%s\n' "$api_key" | CODEWHALE_HOME="$home" "$bin" auth set --provider deepseek >"$log" 2>&1; then
if grep -q 'config lock' "$log"; then
cat "$log" >&2
echo 'BLOCKED: the config layer refused before the secret store was reached.' >&2
echo ' That is the crates/config write lock, not this store.' >&2
exit 3
fi
cat "$log" >&2
fail 'auth set failed; the store could not be written on this mount'
fi
grep -q 'saved API key' "$log" || {
cat "$log" >&2
fail 'auth set did not report saving the key'
}
echo 'ok: key accepted'
echo '--- 2. the key is readable back through the store'
# `auth status` names the layer that answered, so this proves the store supplied
# the key rather than the environment or config.toml.
if ! CODEWHALE_HOME="$home" "$bin" auth status --provider deepseek >"$log" 2>&1; then
cat "$log" >&2
fail 'auth status failed'
fi
grep -q 'active source: secret store' "$log" || {
cat "$log" >&2
fail 'the stored key is not the active source'
}
grep -q 'secret store: file-based.*(set,' "$log" || {
cat "$log" >&2
fail 'the secret store does not report the key as set'
}
if grep -q 'env var: DEEPSEEK_API_KEY (set' "$log"; then
cat "$log" >&2
fail 'DEEPSEEK_API_KEY is set in this shell and would mask the store; unset it and retry'
fi
echo 'ok: auth status resolves the key from the secret store'
echo '--- 3. the config file holds no plaintext key'
config=$home/config.toml
[ -f "$config" ] || fail "no config.toml written at $config"
if grep -qF -- "$api_key" "$config"; then
fail 'the API key was written to config.toml in plaintext'
fi
echo 'ok: config.toml has no plaintext key'
echo '--- 4. the secrets file exists, and is owner-only where the mount can express that'
secrets=$home/secrets/secrets.json
[ -f "$secrets" ] || fail "no secrets.json written at $secrets"
secrets_mode=$(mode_of "$secrets")
echo " secrets.json mode: $secrets_mode"
if [ "$mount_note" = 'stores mode bits' ]; then
case $secrets_mode in
600 | 400 | 0) echo 'ok: owner-only' ;;
*) fail "a mount that stores mode bits left secrets.json at $secrets_mode, not 0600" ;;
esac
else
echo 'ok: the mount reports its own mode, and the store accepted it deliberately'
fi
echo
echo "PASS: the secret store works on $dir"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env sh
# Cross-build and self-sign the Codewhale Engine for a HarmonyOS / OpenHarmony
# device, using the signing tools inside the DevEco Studio SDK.
#
# A binary only runs on device when it carries a code signature: the loader
# refuses an unsigned ELF, and the failure it reports ("Permission denied") is
# indistinguishable from a plain missing exec bit. Native HarmonyOS therefore
# needs both this step and `strip` before the file is useful.
#
# The signature is SELF-SIGNED. `binary-sign-tool` writes a descriptor digest
# and sets FLAG_SELF_SIGN; it does not embed a certificate and needs no private
# key. That is the same shape as the binary already running on the device —
# `display-sign` reports "code signature is self-sign" for both. It is not an
# AppGallery-signed build and carries no bundle identity.
#
# Usage:
# scripts/ohos-sign-binary.sh [--strip] [--out PATH]
#
# Requires:
# OHOS_NATIVE_SDK the OpenHarmony native SDK (llvm/, sysroot/, build-tools/)
#
# Steps this runs:
# 1. cargo build --release --target aarch64-unknown-linux-ohos -p codewhale-cli
# 2. llvm-strip the copy (optional, ~2.6 MiB smaller)
# 3. binary-sign-tool sign -selfSign 1
# 4. binary-sign-tool display-sign, so a failure is not silent
set -eu
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo_root"
strip_binary=no
out=
while [ $# -gt 0 ]; do
case $1 in
--strip) strip_binary=yes; shift ;;
--out) out=${2:?--out needs a path}; shift 2 ;;
-h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ -z "${OHOS_NATIVE_SDK:-}" ]; then
echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory." >&2
echo " e.g. /Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native" >&2
exit 1
fi
sdk=$(cd "$OHOS_NATIVE_SDK" && pwd)
toolchains=$sdk/../toolchains
sign_jar=$toolchains/lib/binary-sign-tool.jar
# The keystore is only consulted for its alias name in self-sign mode; the
# password is the published one that ships with the SDK sample material.
keystore=$toolchains/lib/OpenHarmony.p12
keystore_pwd=123456
key_alias='openharmony application release'
strip_tool=$sdk/llvm/bin/llvm-strip
for file in "$sign_jar" "$keystore" "$strip_tool"; do
[ -f "$file" ] || { echo "error: missing SDK file: $file" >&2; exit 1; }
done
command -v java >/dev/null 2>&1 || { echo "error: java not found; install a JDK or use DevEco's jbr." >&2; exit 1; }
# 1. Build. The environment is shared with the documented manual flow so there
# is one place that decides the target flags.
# shellcheck disable=SC1091
. ./scripts/ohos-env.sh >/dev/null
echo "== building codewhale-cli for aarch64-unknown-linux-ohos"
cargo build --locked --release --target aarch64-unknown-linux-ohos -p codewhale-cli
built=target/aarch64-unknown-linux-ohos/release/codewhale
[ -f "$built" ] || { echo "error: build produced no binary at $built" >&2; exit 1; }
out=${out:-target/aarch64-unknown-linux-ohos/release/codewhale-signed}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
# 2. Strip a copy. Never strip the build output in place: cargo would consider
# the artifact current and leave a stripped binary where it expects an
# unstripped one.
cp "$built" "$work/unsigned"
if [ "$strip_binary" = yes ]; then
echo "== stripping"
"$strip_tool" --strip-unneeded "$work/unsigned"
fi
# 3. Sign.
echo "== signing (self-sign)"
java -jar "$sign_jar" sign \
-mode localSign \
-keyAlias "$key_alias" \
-keystoreFile "$keystore" \
-keystorePwd "$keystore_pwd" \
-inFile "$work/unsigned" \
-signAlg SHA256withECDSA \
-outFile "$work/signed" \
-selfSign 1 2>&1 | sed -n 's/^.*INFO - / /p'
# 4. Verify, and refuse to hand back a file that did not sign.
echo "== verifying"
verify=$(java -jar "$sign_jar" display-sign -inFile "$work/signed" 2>&1) || {
echo "$verify" >&2
echo "error: display-sign failed" >&2
exit 1
}
case $verify in
*"code signature is self-sign"*) echo " code signature is self-sign" ;;
*) echo "$verify" >&2; echo "error: the output is not self-signed" >&2; exit 1 ;;
esac
cp "$work/signed" "$out"
chmod +x "$out"
echo
echo "signed: $out"
echo "size : $(wc -c < "$out") bytes"
echo "sha256: $(shasum -a 256 "$out" | cut -d' ' -f1)"
cat <<'NOTE'
To run it on a device, note that two separate gates apply:
* the file needs its exec bits. HarmonyOS hmdfs (the user directory) refuses
`chmod`, and `hdc file send` creates files at 0660, so copy on device from
something already executable:
cp ~/.cargo/bin/codewhale ~/codewhale-new # keeps 0750
# then overwrite its content with the new build
* it must be executed from an app process (HiShell, or the CodeArts
integrated terminal). The hdc debug shell cannot exec a file on hmdfs at
all — it reports "Permission denied" even for a binary that runs fine
elsewhere.
NOTE