mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
feat(runtime): keep private state on a filesystem that cannot express ownership
A GUI start on HarmonyOS hmdfs failed one feature at a time: the workspace drive lease, then the owner control socket as `failed to build daemon state`, then the OAuth credential directory as `Codewhale credentials directory must be owned by the current user`. Each of those admitted a directory by proving it was current-user-owned and closed to group and other, and on that mount no directory can: it reports the logged-in user's uid and a group-writable mode for every object, whatever uid created it, so the calling process is never the reported owner and `chmod` answers EPERM. Each fix revealed the next, because the same mechanism sits under every private directory Codewhale keeps. The mechanism now names the condition once. `private_footing` creates one `0o700` directory under the target, reads back what the mount made of it, and — when that is not the private owner and mode it asked for — asks the mount to narrow it and looks again. Only a mount that refuses, or that keeps its own values after being asked, is synthesising; a filesystem that applies an unexpected mode at creation but honours the correction stores modes and keeps the full guard, so a default ACL cannot silently relax a check. The probe runs only after a strict check has already failed, and its answer (with its one warning) is remembered per directory, so no write path probes a healthy filesystem. Where the mount is synthesising, the private object is admitted with the ownership guard recorded as unverifiable. The no-follow component walk, the single-link check, atomic publication and the retained handle all still apply, and the mount's own access control governs the rest. Two capabilities are kept apart, because conflating them is what made the original failure look like a missing feature. Exclusivity is the kernel lock, not the ownership proof: the workspace lease is still taken, and a second driver is still refused with the holder's pid, client, store and age. Only a filesystem that refuses advisory locks themselves (`EPERM`, `EACCES`, `ENOTSUP`, `EINVAL`, `ENOSYS`, and never `EWOULDBLOCK`) is reported as unable to provide exclusivity, once per admitted workspace scope. The owner control socket has nothing else to fall back on for discovery, so it degrades to HTTP only and says so; a daemon-only run and ACP publication still require it and name the reason. Narrowing a file or directory after writing it goes through one helper that attempts the `chmod`, keeps the error on a filesystem that stores modes, and excuses only a mount that cannot apply them — applied to the handle that was opened where one exists, so a substituted path cannot be the object narrowed. Checks that repeated the ownership proof on an object already admitted (the retained-directory identity comparison, an owner receipt's mode, a published socket's owner) now follow the guard it was admitted with. `scripts/ohos-sign-binary.sh` cross-builds, strips and self-signs the Engine and verifies the signature before handing the file back; `scripts/ohos-lease-footing-probe.sh` reports what a directory on a device does with ownership, mode and advisory locks; `scripts/ohos-secrets-store-smoke.sh` exercises the secret store's degraded path against a real mount.
This commit is contained in:
1 parent
a5c796e848
commit
95783672bf
35 files changed
+2212
-238
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -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()
|
||||
@@ -265,6 +262,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();
|
||||
|
||||
@@ -22,6 +22,9 @@ pub struct PrivateDirectory {
|
||||
pub(crate) directory: PathBuf,
|
||||
#[cfg(unix)]
|
||||
pub(crate) directory_handle: File,
|
||||
/// Whether ownership and mode were actually verifiable on this mount.
|
||||
#[cfg(unix)]
|
||||
pub(crate) guard: PrivateGuard,
|
||||
#[cfg(windows)]
|
||||
pub(crate) _component_handles: Vec<File>,
|
||||
}
|
||||
@@ -44,16 +47,99 @@ impl std::error::Error for UnlinkedPrivateFile {}
|
||||
|
||||
impl PrivateDirectory {
|
||||
pub fn open(directory: &Path) -> Result<Self> {
|
||||
open_owned_directory(directory, true, true)
|
||||
open_owned_directory(directory, true, true, PrivateGuard::Required)
|
||||
}
|
||||
/// Read-only admission of an existing private directory, without repair.
|
||||
pub fn inspect(directory: &Path) -> Result<Self> {
|
||||
open_owned_directory(directory, false, false)
|
||||
open_owned_directory(directory, false, false, PrivateGuard::Required)
|
||||
}
|
||||
|
||||
/// Create missing owned directories; refuse existing non-private permissions.
|
||||
pub fn admit(directory: &Path) -> Result<Self> {
|
||||
open_owned_directory(directory, false, true)
|
||||
open_owned_directory(directory, false, true, PrivateGuard::Required)
|
||||
}
|
||||
|
||||
/// Create and admit, accepting a mount that cannot express ownership.
|
||||
///
|
||||
/// On a filesystem that stores ownership and mode this is exactly
|
||||
/// [`Self::open`]. On one that synthesises them — HarmonyOS hmdfs reports
|
||||
/// the logged-in user's uid and a group-writable mode for every object an
|
||||
/// app creates and reports that user as the owner of every object, so a
|
||||
/// process is never the owner of what it creates and `chmod` answers
|
||||
/// `EPERM` — the strict proof can never hold, so
|
||||
/// refusing here would make the feature unusable on that platform for
|
||||
/// everyone. The object is admitted with [`PrivateGuard::Unverifiable`],
|
||||
/// which relaxes *only* the ownership proof; the no-follow walk, the
|
||||
/// single-link check and atomic publication still apply, and a warning
|
||||
/// names the values the mount reported.
|
||||
///
|
||||
/// A directory this process may not enter is still an error: the probe must
|
||||
/// be able to create an object of its own before a mount is blamed.
|
||||
pub fn open_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::with_mount_tolerance(directory, true, true)
|
||||
}
|
||||
|
||||
/// [`Self::admit`], accepting a mount that cannot express ownership.
|
||||
pub fn admit_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::with_mount_tolerance(directory, false, true)
|
||||
}
|
||||
|
||||
/// [`Self::inspect`], accepting a mount that cannot express ownership.
|
||||
pub fn inspect_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::with_mount_tolerance(directory, false, false)
|
||||
}
|
||||
|
||||
/// Not applicable outside Unix: the ownership guard is the Windows security
|
||||
/// descriptor, which every local filesystem stores.
|
||||
#[cfg(not(unix))]
|
||||
pub fn open_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::open(directory)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn admit_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::admit(directory)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn inspect_trusting_mount(directory: &Path) -> Result<Self> {
|
||||
Self::inspect(directory)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn with_mount_tolerance(directory: &Path, protect: bool, create: bool) -> Result<Self> {
|
||||
match open_owned_directory(directory, protect, create, PrivateGuard::Required) {
|
||||
Ok(private) => Ok(private),
|
||||
Err(strict_error) => {
|
||||
// `mount_footing` answers once per directory and reports the
|
||||
// values it saw; a probe that could not run answers `Native`,
|
||||
// which keeps the strict error below.
|
||||
let PrivateFooting::Synthesised {
|
||||
observed_uid,
|
||||
observed_mode,
|
||||
} = mount_footing(directory)
|
||||
else {
|
||||
return Err(strict_error);
|
||||
};
|
||||
let private = open_owned_directory(
|
||||
directory,
|
||||
protect,
|
||||
create,
|
||||
PrivateGuard::Unverifiable {
|
||||
observed_uid,
|
||||
observed_mode,
|
||||
},
|
||||
)?;
|
||||
tracing::warn!(
|
||||
directory = %private.directory.display(),
|
||||
"{} reports uid {observed_uid} and mode {observed_mode:o} for objects created on \
|
||||
it, so the ownership guard cannot be verified; relying on the mount's own \
|
||||
access control for this private directory",
|
||||
private.directory.display()
|
||||
);
|
||||
Ok(private)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the retained directory against the lexical selection without
|
||||
@@ -61,12 +147,32 @@ impl PrivateDirectory {
|
||||
#[cfg(unix)]
|
||||
pub fn is_at_selected_path(&self) -> Result<bool> {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let current = Self::inspect(&self.directory)?;
|
||||
// What this compares is identity — device and inode of the retained
|
||||
// handle against the freshly opened one — so repeating the ownership
|
||||
// proof adds nothing here, and a directory admitted as `Unverifiable`
|
||||
// would fail that proof on exactly the mount that made it necessary.
|
||||
let current = match self.guard {
|
||||
PrivateGuard::Required => Self::inspect(&self.directory)?,
|
||||
PrivateGuard::Unverifiable { .. } => Self::inspect_trusting_mount(&self.directory)?,
|
||||
};
|
||||
let held = self.directory_handle.metadata()?;
|
||||
let now = current.directory_handle.metadata()?;
|
||||
Ok(held.dev() == now.dev() && held.ino() == now.ino())
|
||||
}
|
||||
|
||||
/// The guard this directory was admitted with, for validating files under
|
||||
/// it. A file on a mount that cannot express ownership is exactly as
|
||||
/// unverifiable as its directory.
|
||||
#[cfg(unix)]
|
||||
pub fn guard(&self) -> PrivateGuard {
|
||||
self.guard
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn guard(&self) -> PrivateGuard {
|
||||
PrivateGuard::Required
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn current_user_id() -> u32 {
|
||||
// SAFETY: geteuid has no pointer arguments.
|
||||
@@ -74,6 +180,380 @@ impl PrivateDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrow `path` to `mode`, tolerating a filesystem that cannot store modes.
|
||||
///
|
||||
/// Every ownership story in this crate ends in a `chmod`: a lock file is
|
||||
/// narrowed to `0600`, a session directory to `0700`. On HarmonyOS hmdfs the
|
||||
/// call fails with `EPERM` for every object, including objects the caller just
|
||||
/// created, because the mount assigns the mode itself. Treating that as fatal
|
||||
/// turns an ordinary write into "permission denied" on a platform where the
|
||||
/// write itself succeeded — and the mode the caller asked for was never going
|
||||
/// to be applied.
|
||||
///
|
||||
/// This narrows when the mount stores modes and, only where a probe proves the
|
||||
/// mount synthesises them, continues. A mount that stores modes keeps the
|
||||
/// error, so a real permission problem is still reported; the probe's own
|
||||
/// warning names the values the mount reported, once per directory.
|
||||
#[cfg(unix)]
|
||||
pub fn tighten_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
match fs::set_permissions(path, fs::Permissions::from_mode(mode)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
// A mount that assigns modes itself refuses this for every object
|
||||
// it holds, including one it has just accepted a write to. Only
|
||||
// that mount is excused, and `mount_footing` answers once per
|
||||
// directory, so a write path does not pay for a probe each time.
|
||||
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
if parent.is_some_and(|parent| {
|
||||
matches!(mount_footing(parent), PrivateFooting::Synthesised { .. })
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(error)
|
||||
.with_context(|| format!("narrowing {} to {mode:o}", crate::quote_os_path(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`tighten_mode`] for an object this process already holds open.
|
||||
///
|
||||
/// Preferred wherever a handle exists: the mode is applied to the object the
|
||||
/// caller validated, so narrowing cannot be redirected to a substituted path.
|
||||
/// Only the *question* about the filesystem is answered by path, and only after
|
||||
/// the call has already failed.
|
||||
#[cfg(unix)]
|
||||
pub fn tighten_mode_of(file: &File, path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let error = match file.set_permissions(fs::Permissions::from_mode(mode)) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => error,
|
||||
};
|
||||
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
if parent
|
||||
.is_some_and(|parent| matches!(mount_footing(parent), PrivateFooting::Synthesised { .. }))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error).with_context(|| format!("narrowing {} to {mode:o}", crate::quote_os_path(path)))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn tighten_mode_of(_file: &File, _path: &Path, _mode: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn tighten_mode(_path: &Path, _mode: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How much of the private-directory guard this object could establish.
|
||||
///
|
||||
/// Everything that makes an object unreachable by another local user — the
|
||||
/// no-follow component walk, the single-link check, atomic publication, the
|
||||
/// retained handle — holds in both cases. Only the proof of *ownership* is what
|
||||
/// a mount can fail to express, and that is what [`Self::Unverifiable`] records.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrivateGuard {
|
||||
/// Ownership and mode prove the directory is current-user-only.
|
||||
Required,
|
||||
/// The mount reports ownership and mode of its own, so they prove nothing.
|
||||
/// The caller asked for this explicitly and has said so to the user; the
|
||||
/// mount's own access control is what governs the object.
|
||||
Unverifiable {
|
||||
observed_uid: u32,
|
||||
observed_mode: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a filesystem does with the owner and mode of a directory created on it.
|
||||
///
|
||||
/// A private directory is admitted by proving it is current-user-owned and
|
||||
/// inaccessible to group or other. Some mounts synthesise those attributes
|
||||
/// instead of storing them, so no object there can ever satisfy that proof —
|
||||
/// and refusing the feature outright turns a filesystem limitation into a
|
||||
/// failed startup. Callers use this to tell "you are not allowed" (fatal) from
|
||||
/// "this mount cannot express the guard" (a capability the caller must
|
||||
/// degrade around, loudly).
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrivateFooting {
|
||||
/// The mount stored the owner and mode the probe directory was created with.
|
||||
Native,
|
||||
/// The mount reported values of its own for the probe directory, with the
|
||||
/// observed values, so a refusal can name what it saw.
|
||||
Synthesised {
|
||||
observed_uid: u32,
|
||||
observed_mode: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Probe the filesystem that would hold `directory`.
|
||||
///
|
||||
/// Creates one directory at `0o700` under the deepest existing ancestor of
|
||||
/// `directory`, reads back what the mount made of it, and removes it. A mount
|
||||
/// that keeps the owner and mode it was given answers
|
||||
/// [`PrivateFooting::Native`]; a mount that invents them (HarmonyOS hmdfs
|
||||
/// reports the logged-in user's uid and a group-writable mode for every object,
|
||||
/// and reports another owner, so `chmod` answers `EPERM`) answers
|
||||
/// [`PrivateFooting::Synthesised`].
|
||||
///
|
||||
/// An unexpected mode alone is *not* enough to blame the mount: a default ACL
|
||||
/// or a setgid parent can widen a new directory on a filesystem that stores
|
||||
/// modes perfectly well. The probe therefore asks the mount to apply `0o700`
|
||||
/// and looks again, and only a mount that will not honour that is called
|
||||
/// synthesising. Reclassifying a mount that does store modes would relax a real
|
||||
/// ownership guard, so the question is asked in the direction that keeps it.
|
||||
///
|
||||
/// Any failure to run the probe is an error rather than an answer, so a
|
||||
/// directory this process cannot write to keeps the strict refusal instead of
|
||||
/// being reclassified as a mount limitation.
|
||||
#[cfg(unix)]
|
||||
pub fn private_footing(directory: &Path) -> Result<PrivateFooting> {
|
||||
anyhow::ensure!(
|
||||
directory.is_absolute(),
|
||||
"private footing probe needs an absolute path"
|
||||
);
|
||||
let Some(parent) = deepest_existing_ancestor(directory) else {
|
||||
// Nothing exists to create in; the real admission reports the cause.
|
||||
return Ok(PrivateFooting::Native);
|
||||
};
|
||||
let handle = File::open(parent).with_context(|| {
|
||||
format!(
|
||||
"opening {} to probe its filesystem",
|
||||
crate::quote_os_path(parent)
|
||||
)
|
||||
})?;
|
||||
probe_footing_in(&handle).with_context(|| {
|
||||
format!(
|
||||
"probing private-directory footing under {}",
|
||||
crate::quote_os_path(parent)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The footing of the filesystem behind an already-open directory.
|
||||
///
|
||||
/// The probe runs inside `directory` through its descriptor, so no path is
|
||||
/// involved and nothing outside it can be reached. Used both by
|
||||
/// [`private_footing`] and by callers that already hold the directory — a
|
||||
/// delivery writer, an anchored report directory — and want the answer for the
|
||||
/// object they are actually writing into.
|
||||
#[cfg(unix)]
|
||||
fn probe_footing_in(directory: &File) -> Result<PrivateFooting> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
|
||||
static PROBE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let directory_fd = directory.as_raw_fd();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos());
|
||||
let mut reserved = None;
|
||||
for _ in 0..8 {
|
||||
let name = CString::new(format!(
|
||||
".codewhale-owner-probe-{}-{nanos}-{}",
|
||||
std::process::id(),
|
||||
PROBE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
))?;
|
||||
// SAFETY: a retained directory descriptor and a single component that
|
||||
// contains no path separator and no interior NUL.
|
||||
if unsafe { libc::mkdirat(directory_fd, name.as_ptr(), 0o700) } == 0 {
|
||||
reserved = Some(name);
|
||||
break;
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
continue;
|
||||
}
|
||||
return Err(error).context("creating private footing probe");
|
||||
}
|
||||
let name = reserved.context("private footing probe could not reserve a name")?;
|
||||
let observed = (|| {
|
||||
let first = probe_stat(directory_fd, &name)?;
|
||||
if matches!(
|
||||
classify_probe_footing(first.0, first.1, PrivateDirectory::current_user_id()),
|
||||
PrivateFooting::Native
|
||||
) {
|
||||
return Ok(PrivateFooting::Native);
|
||||
}
|
||||
// Ask the mount for the mode the probe actually wants, and look again.
|
||||
// SAFETY: the same retained descriptor and the same single component.
|
||||
let narrowed = (unsafe { libc::fchmodat(directory_fd, name.as_ptr(), 0o700, 0) } == 0)
|
||||
.then(|| probe_stat(directory_fd, &name))
|
||||
.transpose()?;
|
||||
Ok(footing_from_observations(
|
||||
first,
|
||||
narrowed,
|
||||
PrivateDirectory::current_user_id(),
|
||||
))
|
||||
})();
|
||||
// Best effort: a probe that cannot be removed must not change the answer.
|
||||
// SAFETY: the same retained descriptor and the same single component.
|
||||
unsafe { libc::unlinkat(directory_fd, name.as_ptr(), libc::AT_REMOVEDIR) };
|
||||
observed
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn probe_stat(directory_fd: libc::c_int, name: &CString) -> Result<(u32, u32)> {
|
||||
let mut metadata = std::mem::MaybeUninit::<libc::stat>::uninit();
|
||||
// SAFETY: one component below a retained descriptor, no follow, writable
|
||||
// output.
|
||||
if unsafe {
|
||||
libc::fstatat(
|
||||
directory_fd,
|
||||
name.as_ptr(),
|
||||
metadata.as_mut_ptr(),
|
||||
libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("inspecting private footing probe");
|
||||
}
|
||||
// SAFETY: a successful `fstatat` initialized the whole object.
|
||||
let metadata = unsafe { metadata.assume_init() };
|
||||
// The field types differ per platform (Darwin's `mode_t` is narrower than
|
||||
// Linux's), so both are read through one pair of accessors.
|
||||
let mode = libc_mode(&metadata);
|
||||
Ok((libc_uid(&metadata), mode & 0o777))
|
||||
}
|
||||
|
||||
/// The rule [`private_footing`] applies, separated so every branch is testable
|
||||
/// on a filesystem that only ever produces the native one.
|
||||
#[cfg(unix)]
|
||||
fn classify_probe_footing(
|
||||
observed_uid: u32,
|
||||
observed_mode: u32,
|
||||
current_uid: u32,
|
||||
) -> PrivateFooting {
|
||||
if observed_uid == current_uid && observed_mode & 0o077 == 0 {
|
||||
PrivateFooting::Native
|
||||
} else {
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid,
|
||||
observed_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole rule: the first observation, and what the mount did when it was
|
||||
/// asked to apply `0o700` (`None` when it refused the request).
|
||||
///
|
||||
/// A second observation that finally shows the private values means the mount
|
||||
/// stores modes and simply applied something of its own at creation — a default
|
||||
/// ACL, a setgid parent. Such a filesystem can hold the guard (the strict
|
||||
/// admission narrows it), so calling it synthesising would relax a real check.
|
||||
/// Only a mount that refuses, or that reports values of its own after being
|
||||
/// asked, is synthesising.
|
||||
#[cfg(unix)]
|
||||
fn footing_from_observations(
|
||||
first: (u32, u32),
|
||||
narrowed: Option<(u32, u32)>,
|
||||
current_uid: u32,
|
||||
) -> PrivateFooting {
|
||||
match narrowed {
|
||||
Some((uid, mode)) => classify_probe_footing(uid, mode, current_uid),
|
||||
None => classify_probe_footing(first.0, first.1, current_uid),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn libc_uid(metadata: &libc::stat) -> u32 {
|
||||
metadata.st_uid
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn libc_mode(metadata: &libc::stat) -> u32 {
|
||||
metadata.st_mode as u32
|
||||
}
|
||||
|
||||
/// Answers already obtained from a filesystem, keyed by the identity of the
|
||||
/// directory that was probed.
|
||||
///
|
||||
/// A probe is a create, a stat, a `chmod` and an unlink, and the sites that ask
|
||||
/// sit under ordinary file writes. The answer is a property of the mount and
|
||||
/// cannot change for one directory, so each directory is asked once per process
|
||||
/// and the one warning is emitted with that first answer. A probe that could
|
||||
/// not run is not an answer and is never cached.
|
||||
#[cfg(unix)]
|
||||
fn footing_answers()
|
||||
-> &'static std::sync::Mutex<std::collections::HashMap<(u64, u64), PrivateFooting>> {
|
||||
static ANSWERS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<(u64, u64), PrivateFooting>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
ANSWERS.get_or_init(std::sync::Mutex::default)
|
||||
}
|
||||
|
||||
/// [`private_footing`] for a directory handle, answered at most once.
|
||||
#[cfg(unix)]
|
||||
pub fn mount_footing_of(directory: &File) -> PrivateFooting {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
|
||||
let key = directory
|
||||
.metadata()
|
||||
.ok()
|
||||
.map(|metadata| (metadata.dev(), metadata.ino()));
|
||||
if let Some(key) = key
|
||||
&& let Some(answer) = footing_answers()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&key)
|
||||
.copied()
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let Ok(answer) = probe_footing_in(directory) else {
|
||||
// The probe could not run: that is a problem to report, not a mount
|
||||
// limitation, so the caller keeps its strict check.
|
||||
return PrivateFooting::Native;
|
||||
};
|
||||
if let PrivateFooting::Synthesised {
|
||||
observed_uid,
|
||||
observed_mode,
|
||||
} = answer
|
||||
{
|
||||
tracing::warn!(
|
||||
"a directory on this filesystem was created at 0o700 and reports uid \
|
||||
{observed_uid} and mode {observed_mode:o}, and the mount will not narrow it; \
|
||||
ownership cannot be verified for private files here, so relying on the mount's \
|
||||
own access control"
|
||||
);
|
||||
}
|
||||
if let Some(key) = key {
|
||||
footing_answers()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.insert(key, answer);
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
/// [`private_footing`] for a path, answered at most once per directory.
|
||||
#[cfg(unix)]
|
||||
pub fn mount_footing(directory: &Path) -> PrivateFooting {
|
||||
let Some(existing) = deepest_existing_ancestor(directory) else {
|
||||
return PrivateFooting::Native;
|
||||
};
|
||||
let Ok(handle) = File::open(existing) else {
|
||||
return PrivateFooting::Native;
|
||||
};
|
||||
mount_footing_of(&handle)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn deepest_existing_ancestor(directory: &Path) -> Option<&Path> {
|
||||
let mut candidate = Some(directory);
|
||||
while let Some(path) = candidate {
|
||||
if fs::symlink_metadata(path).is_ok() {
|
||||
return Some(path);
|
||||
}
|
||||
candidate = path.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn validate_private_basename(name: &str) -> Result<()> {
|
||||
let path = Path::new(name);
|
||||
anyhow::ensure!(
|
||||
@@ -102,7 +582,12 @@ fn resolve_ancestors(directory: &Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
|
||||
fn open_owned_directory(
|
||||
directory: &Path,
|
||||
protect: bool,
|
||||
create: bool,
|
||||
guard: PrivateGuard,
|
||||
) -> Result<PrivateDirectory> {
|
||||
use std::os::fd::FromRawFd as _;
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
|
||||
@@ -215,22 +700,47 @@ fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result
|
||||
metadata.is_dir(),
|
||||
"Codewhale credentials path must be a directory"
|
||||
);
|
||||
// SAFETY: geteuid(2) dereferences no pointers.
|
||||
anyhow::ensure!(
|
||||
metadata.uid() == unsafe { libc::geteuid() },
|
||||
"Codewhale credentials directory must be owned by the current user"
|
||||
);
|
||||
if protect {
|
||||
current.set_permissions(fs::Permissions::from_mode(0o700))?;
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
metadata.mode() & 0o077 == 0,
|
||||
"private endpoint directory must already be current-user-only"
|
||||
);
|
||||
match guard {
|
||||
PrivateGuard::Required => {
|
||||
// The message names the directory and both uids. It used to say
|
||||
// only "credentials directory must be owned by the current user",
|
||||
// which was wrong twice over: the directory is not necessarily a
|
||||
// credential store (the workspace lease and the control socket use
|
||||
// the same mechanism), and without the values there was no way to
|
||||
// tell a real ownership problem from a mount that reports an owner
|
||||
// of its own.
|
||||
// SAFETY: geteuid(2) dereferences no pointers.
|
||||
let current_uid = unsafe { libc::geteuid() };
|
||||
anyhow::ensure!(
|
||||
metadata.uid() == current_uid,
|
||||
"private directory {} is owned by uid {} but this process runs as uid {}",
|
||||
crate::quote_os_path(directory),
|
||||
metadata.uid(),
|
||||
current_uid
|
||||
);
|
||||
if protect {
|
||||
current.set_permissions(fs::Permissions::from_mode(0o700))?;
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
metadata.mode() & 0o077 == 0,
|
||||
"private endpoint directory must already be current-user-only"
|
||||
);
|
||||
}
|
||||
}
|
||||
PrivateGuard::Unverifiable { .. } => {
|
||||
// The mount owns the attributes, so neither the owner nor the mode
|
||||
// can be made to prove anything. Narrowing is still attempted and
|
||||
// its failure is expected rather than fatal: hmdfs answers EPERM to
|
||||
// `chmod` for every object.
|
||||
if protect {
|
||||
let _ = current.set_permissions(fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(PrivateDirectory {
|
||||
directory: directory.to_path_buf(),
|
||||
directory_handle: current,
|
||||
guard,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,8 +793,19 @@ impl PrivateDirectory {
|
||||
let file = self
|
||||
.open_at(name, libc::O_RDWR | libc::O_CREAT, 0o600)?
|
||||
.context("xAI OAuth lifecycle lock disappeared while opening")?;
|
||||
validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
validate_owned_file_handle(&file, &self.directory.join(name), self.guard)?;
|
||||
// Narrowing is a no-op where the mount stores the mode it was given,
|
||||
// and is expected to fail where the mount invents one (hmdfs answers
|
||||
// EPERM to `chmod` for every object). Refusing there would make the
|
||||
// store unwritable on that platform for a mode it never applied anyway.
|
||||
match self.guard {
|
||||
PrivateGuard::Required => {
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
PrivateGuard::Unverifiable { .. } => {
|
||||
let _ = file.set_permissions(fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
@@ -311,7 +832,14 @@ impl PrivateDirectory {
|
||||
temp.write_all(bytes)
|
||||
.context("writing xAI OAuth temporary file")?;
|
||||
temp.flush().context("flushing xAI OAuth temporary file")?;
|
||||
temp.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
match self.guard {
|
||||
PrivateGuard::Required => {
|
||||
temp.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
PrivateGuard::Unverifiable { .. } => {
|
||||
let _ = temp.set_permissions(fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
temp.sync_all()
|
||||
.context("syncing xAI OAuth temporary file")?;
|
||||
|
||||
@@ -321,7 +849,7 @@ impl PrivateDirectory {
|
||||
CString::new(temp_name.as_str()).context("temporary basename contains NUL")?;
|
||||
if allow_replace {
|
||||
if let Some(existing) = self.open_owned_file_for_read(name)? {
|
||||
validate_owned_file_handle(&existing, &self.directory.join(name))?;
|
||||
validate_owned_file_handle(&existing, &self.directory.join(name), self.guard)?;
|
||||
}
|
||||
// SAFETY: both names are relative to the same stable directory
|
||||
// handle; rename is atomic and cannot escape that directory.
|
||||
@@ -397,7 +925,7 @@ impl PrivateDirectory {
|
||||
let Some(file) = self.open_owned_file_for_read(name)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
validate_owned_file_handle(&file, &self.directory.join(name), self.guard)?;
|
||||
drop(file);
|
||||
let name = CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
|
||||
// SAFETY: the name is one component relative to the stable credentials
|
||||
@@ -419,7 +947,7 @@ impl PrivateDirectory {
|
||||
let source = self
|
||||
.open_owned_file_for_read(from)?
|
||||
.context("xAI OAuth source disappeared before retirement")?;
|
||||
validate_owned_file_handle(&source, &self.directory.join(from))?;
|
||||
validate_owned_file_handle(&source, &self.directory.join(from), self.guard)?;
|
||||
anyhow::ensure!(
|
||||
self.open_owned_file_for_read(to)?.is_none(),
|
||||
"refusing to replace an existing xAI OAuth retirement path"
|
||||
@@ -445,7 +973,11 @@ impl PrivateDirectory {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs::Metadata> {
|
||||
pub(crate) fn validate_owned_file_handle(
|
||||
file: &File,
|
||||
path: &Path,
|
||||
guard: PrivateGuard,
|
||||
) -> Result<fs::Metadata> {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let metadata = file.metadata().with_context(|| {
|
||||
format!(
|
||||
@@ -454,11 +986,16 @@ pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs:
|
||||
)
|
||||
})?;
|
||||
anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file");
|
||||
// SAFETY: geteuid(2) dereferences no pointers.
|
||||
anyhow::ensure!(
|
||||
metadata.uid() == unsafe { libc::geteuid() },
|
||||
"xAI OAuth file must be owned by the current user"
|
||||
);
|
||||
// A file on a mount that reports ownership of its own carries the same
|
||||
// evidence as its directory: none. The link count still has to be exact,
|
||||
// because that is a real property the mount stores.
|
||||
if matches!(guard, PrivateGuard::Required) {
|
||||
// SAFETY: geteuid(2) dereferences no pointers.
|
||||
anyhow::ensure!(
|
||||
metadata.uid() == unsafe { libc::geteuid() },
|
||||
"xAI OAuth file must be owned by the current user"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(metadata.nlink() != 0, UnlinkedPrivateFile);
|
||||
anyhow::ensure!(
|
||||
metadata.nlink() == 1,
|
||||
@@ -468,7 +1005,12 @@ pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs:
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
|
||||
fn open_owned_directory(
|
||||
directory: &Path,
|
||||
protect: bool,
|
||||
create: bool,
|
||||
_guard: PrivateGuard,
|
||||
) -> Result<PrivateDirectory> {
|
||||
use std::os::windows::fs::OpenOptionsExt as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ,
|
||||
@@ -1229,7 +1771,12 @@ pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
|
||||
fn open_owned_directory(
|
||||
directory: &Path,
|
||||
protect: bool,
|
||||
create: bool,
|
||||
_guard: PrivateGuard,
|
||||
) -> Result<PrivateDirectory> {
|
||||
anyhow::ensure!(
|
||||
protect,
|
||||
"private endpoint authentication is unsupported on this platform"
|
||||
@@ -1341,10 +1888,15 @@ impl PrivateDirectory {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
anyhow::ensure!(
|
||||
metadata.st_uid == Self::current_user_id(),
|
||||
"private socket is not owned by the current user"
|
||||
);
|
||||
// Ownership is evidence only where the mount stores it. The socket
|
||||
// type above and the identity this returns are stored properties, so
|
||||
// they still hold on a mount that reports an owner of its own.
|
||||
if matches!(self.guard, PrivateGuard::Required) {
|
||||
anyhow::ensure!(
|
||||
metadata.st_uid == Self::current_user_id(),
|
||||
"private socket is not owned by the current user"
|
||||
);
|
||||
}
|
||||
Ok(Some(PrivateSocketIdentity {
|
||||
device: metadata.st_dev,
|
||||
inode: metadata.st_ino,
|
||||
@@ -1467,6 +2019,186 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_footing_is_native_where_the_mount_stores_modes() {
|
||||
let root = root();
|
||||
assert_eq!(
|
||||
private_footing(&root.path().join("state")).unwrap(),
|
||||
PrivateFooting::Native,
|
||||
"a mount that keeps the mode it is given is native footing"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(root.path()).unwrap().count(),
|
||||
0,
|
||||
"the probe must remove itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// The synthesised branch cannot be built on a filesystem that stores
|
||||
/// ownership and mode; the rule is pinned here instead. The observed values
|
||||
/// are the ones HarmonyOS hmdfs produces for an app-created `0o700`
|
||||
/// directory: the logged-in user's uid, and a group-writable mode.
|
||||
#[test]
|
||||
fn probe_footing_separates_a_native_mount_from_one_that_invents_ownership() {
|
||||
assert_eq!(
|
||||
classify_probe_footing(1000, 0o700, 1000),
|
||||
PrivateFooting::Native
|
||||
);
|
||||
assert_eq!(
|
||||
classify_probe_footing(1000, 0o500, 1000),
|
||||
PrivateFooting::Native
|
||||
);
|
||||
assert_eq!(
|
||||
classify_probe_footing(20001006, 0o2771, 20020225),
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid: 20001006,
|
||||
observed_mode: 0o2771
|
||||
},
|
||||
"hmdfs: a different uid and a group-writable mode for our own directory"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_probe_footing(20020225, 0o2771, 20020225),
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid: 20020225,
|
||||
observed_mode: 0o2771
|
||||
},
|
||||
"the right owner with the wrong mode is still not a private directory"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_probe_footing(0, 0o700, 1000),
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid: 0,
|
||||
observed_mode: 0o700
|
||||
},
|
||||
"a root-owned directory is not ours even when the mode looks right"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mount that applied an unexpected mode at creation but honours the one
|
||||
/// it is asked for stores modes: it must not be reclassified as one that
|
||||
/// cannot express ownership, because that would relax a real guard. This is
|
||||
/// the difference between a default ACL and hmdfs.
|
||||
#[test]
|
||||
fn narrowing_rescues_a_mode_storing_mount_and_not_a_synthesising_one() {
|
||||
// A default ACL widened the new directory, and the mount then applied
|
||||
// the 0o700 it was asked for.
|
||||
assert_eq!(
|
||||
footing_from_observations((1000, 0o750), Some((1000, 0o700)), 1000),
|
||||
PrivateFooting::Native
|
||||
);
|
||||
// hmdfs refuses the request and keeps reporting its own values.
|
||||
assert_eq!(
|
||||
footing_from_observations((20001006, 0o2771), None, 20020225),
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid: 20001006,
|
||||
observed_mode: 0o2771
|
||||
}
|
||||
);
|
||||
// A mount that accepts the call but ignores it is still synthesising.
|
||||
assert_eq!(
|
||||
footing_from_observations((20001006, 0o2771), Some((20001006, 0o2771)), 20020225),
|
||||
PrivateFooting::Synthesised {
|
||||
observed_uid: 20001006,
|
||||
observed_mode: 0o2771
|
||||
}
|
||||
);
|
||||
// A mount that honours it and is owned by us holds the guard.
|
||||
assert_eq!(
|
||||
footing_from_observations((1000, 0o700), None, 1000),
|
||||
PrivateFooting::Native,
|
||||
"the first observation already proves a native mount"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cached entry point is what the write paths call, so it must answer
|
||||
/// the same thing the probe does and leave nothing behind.
|
||||
#[test]
|
||||
fn cached_footing_answers_native_here_and_leaves_nothing_behind() {
|
||||
let root = root();
|
||||
assert_eq!(
|
||||
mount_footing(&root.path().join("state")),
|
||||
PrivateFooting::Native
|
||||
);
|
||||
assert_eq!(
|
||||
mount_footing(&root.path().join("state")),
|
||||
PrivateFooting::Native,
|
||||
"the cached answer is the same answer"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(root.path()).unwrap().count(),
|
||||
0,
|
||||
"no probe may survive either answer"
|
||||
);
|
||||
let handle = File::open(root.path()).unwrap();
|
||||
assert_eq!(mount_footing_of(&handle), PrivateFooting::Native);
|
||||
}
|
||||
|
||||
/// The trusting constructors exist for one reason: on a mount that reports
|
||||
/// its own ownership, the strict proof can never hold, and refusing would
|
||||
/// disable the feature for every app on that platform. On a filesystem that
|
||||
/// stores ownership they must behave exactly like the strict ones — this is
|
||||
/// the half a portable test can reach.
|
||||
#[test]
|
||||
fn trusting_constructors_keep_the_strict_guard_where_the_mount_stores_it() {
|
||||
let root = root();
|
||||
let selected = root.path().join("strict");
|
||||
fs::create_dir(&selected).unwrap();
|
||||
fs::set_permissions(&selected, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(
|
||||
PrivateDirectory::admit_trusting_mount(&selected).is_err(),
|
||||
"a group-accessible directory is still refused on a native mount"
|
||||
);
|
||||
|
||||
let owned = root.path().join("owned");
|
||||
let private = PrivateDirectory::admit_trusting_mount(&owned).unwrap();
|
||||
assert_eq!(
|
||||
private.guard(),
|
||||
PrivateGuard::Required,
|
||||
"a native mount keeps the full guard"
|
||||
);
|
||||
private
|
||||
.write_owned_file("owner.json", b"{}", false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
private
|
||||
.read_private_receipt("owner.json", 64)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.0,
|
||||
b"{}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A linked component is refused on every mount: the no-follow walk is not
|
||||
/// the part a synthesising filesystem cannot express.
|
||||
#[test]
|
||||
fn trusting_constructors_still_refuse_a_linked_component() {
|
||||
let root = root();
|
||||
let original = root.path().join("original");
|
||||
fs::create_dir(&original).unwrap();
|
||||
symlink(&original, root.path().join("alias")).unwrap();
|
||||
assert!(PrivateDirectory::admit_trusting_mount(&root.path().join("alias")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_that_cannot_run_is_an_error_not_a_mount_limitation() {
|
||||
if PrivateDirectory::current_user_id() == 0 {
|
||||
// Root ignores the mode this test relies on.
|
||||
return;
|
||||
}
|
||||
let root = root();
|
||||
let blocked = root.path().join("blocked");
|
||||
fs::create_dir(&blocked).unwrap();
|
||||
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let result = private_footing(&blocked.join("state"));
|
||||
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a directory this process cannot write to keeps the strict refusal instead of \
|
||||
being reclassified as a mount that synthesises ownership"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_receipt_read_is_bounded_and_refuses_links_or_public_permissions() {
|
||||
let root = root();
|
||||
@@ -2043,9 +2775,14 @@ impl PrivateDirectory {
|
||||
) -> Result<(Vec<u8>, File)> {
|
||||
use std::io::Read as _;
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let before = validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
let before = validate_owned_file_handle(&file, &self.directory.join(name), self.guard)?;
|
||||
// The mode is evidence only where the mount stores it; on a mount that
|
||||
// invents one it is always wider than 0o600 and proves nothing. The
|
||||
// size bound is a stored property and always applies.
|
||||
let mode_private =
|
||||
!matches!(self.guard, PrivateGuard::Required) || before.mode() & 0o077 == 0;
|
||||
anyhow::ensure!(
|
||||
before.mode() & 0o077 == 0 && before.len() <= u64::try_from(max)?,
|
||||
mode_private && before.len() <= u64::try_from(max)?,
|
||||
"private owner receipt permissions or size refused"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
@@ -2056,7 +2793,7 @@ impl PrivateDirectory {
|
||||
.context("receipt limit overflow")?,
|
||||
)
|
||||
.read_to_end(&mut bytes)?;
|
||||
let after = validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
let after = validate_owned_file_handle(&file, &self.directory.join(name), self.guard)?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() <= max
|
||||
&& u64::try_from(bytes.len())? == before.len()
|
||||
@@ -2070,11 +2807,12 @@ impl PrivateDirectory {
|
||||
|
||||
pub fn retire_private_receipt(&self, name: &str, captured: &File) -> Result<bool> {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let expected = validate_owned_file_handle(captured, &self.directory.join(name))?;
|
||||
let expected =
|
||||
validate_owned_file_handle(captured, &self.directory.join(name), self.guard)?;
|
||||
let Some(named) = self.open_owned_file_for_read(name)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let actual = validate_owned_file_handle(&named, &self.directory.join(name))?;
|
||||
let actual = validate_owned_file_handle(&named, &self.directory.join(name), self.guard)?;
|
||||
if (expected.dev(), expected.ino()) != (actual.dev(), actual.ino()) {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -2087,7 +2825,8 @@ impl PrivateDirectory {
|
||||
let Some(moved) = self.open_owned_file_for_read(&retired)? else {
|
||||
bail!("private receipt retirement is uncertain")
|
||||
};
|
||||
let moved_metadata = validate_owned_file_handle(&moved, &self.directory.join(&retired))?;
|
||||
let moved_metadata =
|
||||
validate_owned_file_handle(&moved, &self.directory.join(&retired), self.guard)?;
|
||||
if (expected.dev(), expected.ino()) != (moved_metadata.dev(), moved_metadata.ino()) {
|
||||
let restored = self.move_no_replace(&retired, name);
|
||||
bail!("private receipt changed; retaining replacement (restore: {restored:?})");
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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))]
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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,90 @@ 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 {
|
||||
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 +223,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 +249,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 +332,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 +394,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 +439,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 +451,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 +572,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 +609,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 +643,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 +690,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 +703,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 +714,44 @@ 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.
|
||||
#[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 +856,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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(©_source).is_ok_and(|bytes| {
|
||||
codewhale_config::private_directory::PrivateDirectory::open(
|
||||
codewhale_config::private_directory::PrivateDirectory::open_trusting_mount(
|
||||
©_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(©_source).is_ok_and(|bytes| {
|
||||
codewhale_config::private_directory::PrivateDirectory::open(
|
||||
codewhale_config::private_directory::PrivateDirectory::open_trusting_mount(
|
||||
©_home,
|
||||
)
|
||||
.and_then(|directory| {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -2024,12 +2025,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)
|
||||
}
|
||||
|
||||
@@ -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(¤t),
|
||||
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(¤t, 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)]
|
||||
{
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Executable
+175
@@ -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
|
||||
Executable
+169
@@ -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"
|
||||
Executable
+131
@@ -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
|
||||
Reference in new issue
Block a user