mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
Merge pull request #7168 from gaord/feat/hmdfs-ownerless-mount
fix(runtime): keep private state on a filesystem that cannot express ownership
This commit is contained in:
38 files changed
+2739
-281
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()
|
||||
@@ -369,6 +366,8 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_uses_owner_only_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("state.json");
|
||||
atomic_write(&path, b"x").unwrap();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -253,7 +253,8 @@ impl XaiOAuthCredentialStore {
|
||||
let Some(mut file) = self.open_owned_file_for_read(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let metadata = validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
let metadata =
|
||||
validate_owned_file_handle(&file, &self.directory.join(name), self.private.guard())?;
|
||||
if metadata.len() > XAI_OAUTH_FILE_LIMIT {
|
||||
bail!(
|
||||
"Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
|
||||
@@ -625,9 +626,17 @@ fn is_chatgpt_owned_auth_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
|
||||
// Sign-in has to work on a filesystem that cannot express ownership, or it
|
||||
// cannot work there at all: HarmonyOS hmdfs reports the logged-in user's
|
||||
// uid and a group-writable mode for every object an app creates and refuses
|
||||
// `chmod`, so the strict proof can never hold. The trusting admission keeps
|
||||
// the no-follow walk, the single-link check and atomic publication, and
|
||||
// relies on the mount's own access control for the rest — the same tradeoff
|
||||
// the file-backed secret store already makes on that mount, with the same
|
||||
// warning.
|
||||
Ok(XaiOAuthCredentialStore {
|
||||
directory: directory.to_path_buf(),
|
||||
private: crate::private_directory::PrivateDirectory::open(directory)?,
|
||||
private: crate::private_directory::PrivateDirectory::open_trusting_mount(directory)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -918,8 +927,12 @@ mod tests {
|
||||
verify_windows_owner_only_handle(directory).expect("current-user-only directory");
|
||||
|
||||
let lock = store.open_lock_file().expect("create lifecycle lock");
|
||||
validate_owned_file_handle(&lock, &credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME))
|
||||
.expect("current-user-only lifecycle lock");
|
||||
validate_owned_file_handle(
|
||||
&lock,
|
||||
&credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME),
|
||||
store.private.guard(),
|
||||
)
|
||||
.expect("current-user-only lifecycle lock");
|
||||
|
||||
let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
|
||||
store
|
||||
@@ -929,8 +942,12 @@ mod tests {
|
||||
.open_owned_file_for_read(generation)
|
||||
.expect("open generation")
|
||||
.expect("generation exists");
|
||||
validate_owned_file_handle(&generation_file, &credentials.join(generation))
|
||||
.expect("current-user-only generation");
|
||||
validate_owned_file_handle(
|
||||
&generation_file,
|
||||
&credentials.join(generation),
|
||||
store.private.guard(),
|
||||
)
|
||||
.expect("current-user-only generation");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -15,6 +15,67 @@ use std::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Whether this path's directory refuses the mode a file is created with *and*
|
||||
/// the one it is then asked for.
|
||||
///
|
||||
/// Some mounts synthesise mode bits instead of storing them: HarmonyOS hmdfs
|
||||
/// (the 2in1 user directory) reports a group-readable mode for every file an
|
||||
/// app creates, and `chmod` answers `EPERM` because that owner is not the
|
||||
/// calling process. There the mode says nothing about who can
|
||||
/// read the database, and refusing the store would leave the platform with no
|
||||
/// memory database at all. This creates one probe file at `0600` beside `path`,
|
||||
/// and — when the mode came back wider — asks the mount to narrow it and looks
|
||||
/// again. A default ACL or a setgid parent widens a new file on a filesystem
|
||||
/// that stores modes perfectly well; such a mount honours the second request
|
||||
/// and keeps the strict refusal, because its mode really does describe access.
|
||||
///
|
||||
/// Any failure to run the probe answers `false`, so a directory we cannot write
|
||||
/// to keeps the strict check rather than being reclassified as a mount limit.
|
||||
///
|
||||
/// The same rule, with the same reasoning, lives in
|
||||
/// `codewhale_config::private_directory::private_footing`; this crate has no
|
||||
/// dependency on it and the probe is a dozen lines, so it is repeated here
|
||||
/// rather than widening this crate's dependency graph.
|
||||
#[cfg(unix)]
|
||||
fn mount_ignores_unix_modes(path: &Path) -> bool {
|
||||
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
|
||||
|
||||
let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let probe = parent.join(format!(
|
||||
".codewhale-mode-probe-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.subsec_nanos())
|
||||
));
|
||||
let created = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&probe);
|
||||
let Ok(file) = created else { return false };
|
||||
let private = |file: &fs::File| {
|
||||
file.metadata()
|
||||
.is_ok_and(|metadata| metadata.permissions().mode() & 0o077 == 0)
|
||||
};
|
||||
let wider = file
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.permissions().mode() & 0o077 != 0);
|
||||
let refuses_narrowing = wider
|
||||
&& match file.set_permissions(fs::Permissions::from_mode(0o600)) {
|
||||
Ok(()) => !private(&file),
|
||||
Err(_) => true,
|
||||
};
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&probe);
|
||||
refuses_narrowing
|
||||
}
|
||||
|
||||
/// Nesting-safe transaction scope for `&self` methods. rusqlite's
|
||||
/// `unchecked_transaction` issues a literal `BEGIN`, which fails inside an
|
||||
/// already-open transaction; engine methods compose inside each other's
|
||||
@@ -331,7 +392,7 @@ impl Store {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if meta.permissions().mode() & 0o077 != 0 {
|
||||
if meta.permissions().mode() & 0o077 != 0 && !mount_ignores_unix_modes(path) {
|
||||
return Err(Error::Invalid(
|
||||
"database permissions must exclude group and world access".into(),
|
||||
));
|
||||
|
||||
@@ -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,91 @@ struct LeaseInner {
|
||||
state: std::sync::Mutex<Option<(WorkspaceDriveLease, usize)>>,
|
||||
}
|
||||
|
||||
/// Why the lease could not be taken even though no other process holds it.
|
||||
///
|
||||
/// This is a capability the filesystem lacks, not a refusal: the caller decides
|
||||
/// whether to run without workspace exclusivity, and the reason is carried so
|
||||
/// that decision can be explained to the user instead of looking like success.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LeaseUnavailability {
|
||||
/// The lease state root whose filesystem could not carry the lease.
|
||||
pub directory: PathBuf,
|
||||
/// What the mount did, in terms a user can act on.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl LeaseUnavailability {
|
||||
#[cfg(unix)]
|
||||
fn new(directory: &Path, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
directory: directory.to_path_buf(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LeaseUnavailability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} cannot provide workspace exclusivity: {}",
|
||||
self.directory.display(),
|
||||
self.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One raw attempt to take a workspace's drive lease.
|
||||
///
|
||||
/// Public because [`WorkspaceDriveLease::try_acquire`] is: it is how an
|
||||
/// embedder that wants the lease without the shared-slot bookkeeping asks.
|
||||
pub enum LeaseAttempt {
|
||||
Acquired(WorkspaceDriveLease),
|
||||
/// A foreign process holds the kernel lock.
|
||||
Contended,
|
||||
/// No lock can be taken here at all.
|
||||
Unsupported(LeaseUnavailability),
|
||||
}
|
||||
|
||||
/// The outcome of admitting one driving engine for a workspace.
|
||||
pub enum LeaseAdmission {
|
||||
/// This process holds (or shares) the workspace drive lease.
|
||||
Held(SharedWorkspaceDriveLease),
|
||||
/// Another process drives this workspace: refuse and name its holder.
|
||||
Contended,
|
||||
/// This filesystem cannot carry an exclusive lease. Running without one is
|
||||
/// the caller's decision, and must be reported rather than assumed.
|
||||
Unsupported(LeaseUnavailability),
|
||||
}
|
||||
|
||||
/// The held handle is a guard, not a fact worth printing; the other two
|
||||
/// outcomes are what a caller or a test needs to see.
|
||||
impl std::fmt::Debug for LeaseAdmission {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Held(_) => f.write_str("Held(..)"),
|
||||
Self::Contended => f.write_str("Contended"),
|
||||
Self::Unsupported(unavailable) => {
|
||||
f.debug_tuple("Unsupported").field(unavailable).finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedWorkspaceDriveLease {
|
||||
/// Acquire (or join) this process's drive lease for the workspace.
|
||||
///
|
||||
/// `None` means a *foreign process* holds it — the caller refuses with the
|
||||
/// holder's facts from [`WorkspaceDriveLease::read_holder`]. Same-process
|
||||
/// joins never fail and never re-lock.
|
||||
/// [`LeaseAdmission::Contended`] means a *foreign process* holds it — the
|
||||
/// caller refuses with the holder's facts from
|
||||
/// [`WorkspaceDriveLease::read_holder`]. [`LeaseAdmission::Unsupported`]
|
||||
/// means the filesystem cannot carry the lease at all, which is a different
|
||||
/// answer and a different decision. Same-process joins never fail and never
|
||||
/// re-lock.
|
||||
pub fn acquire(
|
||||
workspace: &Path,
|
||||
client: Option<&str>,
|
||||
store_root: Option<&Path>,
|
||||
) -> Result<Option<Self>> {
|
||||
) -> Result<LeaseAdmission> {
|
||||
let lease_key = WorkspaceDriveLease::lease_path(workspace)?;
|
||||
let slot = {
|
||||
let mut registry = registry()
|
||||
@@ -137,15 +224,18 @@ impl SharedWorkspaceDriveLease {
|
||||
if let Some(slot) = registry.get(&lease_key) {
|
||||
slot.clone()
|
||||
} else {
|
||||
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)?
|
||||
else {
|
||||
return Ok(None);
|
||||
let lease = match WorkspaceDriveLease::try_acquire(workspace, client, store_root)? {
|
||||
LeaseAttempt::Acquired(lease) => lease,
|
||||
LeaseAttempt::Contended => return Ok(LeaseAdmission::Contended),
|
||||
LeaseAttempt::Unsupported(unavailable) => {
|
||||
return Ok(LeaseAdmission::Unsupported(unavailable));
|
||||
}
|
||||
};
|
||||
let inner = std::sync::Arc::new(LeaseInner {
|
||||
state: std::sync::Mutex::new(Some((lease, 1))),
|
||||
});
|
||||
registry.insert(lease_key, inner.clone());
|
||||
return Ok(Some(Self { inner }));
|
||||
return Ok(LeaseAdmission::Held(Self { inner }));
|
||||
}
|
||||
};
|
||||
// The returned handle is an owned clone, so moving it out never
|
||||
@@ -160,13 +250,17 @@ impl SharedWorkspaceDriveLease {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some((_lease, count)) = state.as_mut() {
|
||||
*count += 1;
|
||||
return Ok(Some(Self { inner }));
|
||||
return Ok(LeaseAdmission::Held(Self { inner }));
|
||||
}
|
||||
let Some(lease) = WorkspaceDriveLease::try_acquire(workspace, client, store_root)? else {
|
||||
return Ok(None);
|
||||
let lease = match WorkspaceDriveLease::try_acquire(workspace, client, store_root)? {
|
||||
LeaseAttempt::Acquired(lease) => lease,
|
||||
LeaseAttempt::Contended => return Ok(LeaseAdmission::Contended),
|
||||
LeaseAttempt::Unsupported(unavailable) => {
|
||||
return Ok(LeaseAdmission::Unsupported(unavailable));
|
||||
}
|
||||
};
|
||||
*state = Some((lease, 1));
|
||||
Ok(Some(Self { inner }))
|
||||
Ok(LeaseAdmission::Held(Self { inner }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,24 +333,32 @@ impl WorkspaceDriveLease {
|
||||
|
||||
/// Try once to acquire the drive lease for this process.
|
||||
///
|
||||
/// `None` on contention: the kernel lock is held. Use
|
||||
/// [`Self::read_holder`] to name the recorded holder.
|
||||
/// [`LeaseAttempt::Contended`] means the kernel lock is held: use
|
||||
/// [`Self::read_holder`] to name the recorded holder. An unsupported mount
|
||||
/// is reported, not refused.
|
||||
pub fn try_acquire(
|
||||
workspace: &Path,
|
||||
client: Option<&str>,
|
||||
store_root: Option<&Path>,
|
||||
) -> Result<Option<Self>> {
|
||||
) -> Result<LeaseAttempt> {
|
||||
let path = Self::lease_path(workspace)?;
|
||||
let parent = path.parent().context("Workspace lease has no parent")?;
|
||||
let base = parent
|
||||
let state_root = parent
|
||||
.parent()
|
||||
.context("Workspace lease has no state root")?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("Workspace lease name is invalid")?;
|
||||
let base = PrivateDirectory::admit(base)?;
|
||||
let directory = PrivateDirectory::admit(parent)?;
|
||||
// Admission relaxes *only* the proof that the directory is
|
||||
// current-user-only, and only where the mount cannot express it. The
|
||||
// exclusivity this function exists for is the kernel lock taken below,
|
||||
// which is a different capability: on HarmonyOS hmdfs `flock` works
|
||||
// and only the reported owner is unusable, so giving up the lease here
|
||||
// would discard a guarantee that still holds. A mount that truly has
|
||||
// no advisory locks is reported further down, after the lock says so.
|
||||
let base = PrivateDirectory::admit_trusting_mount(state_root)?;
|
||||
let directory = PrivateDirectory::admit_trusting_mount(parent)?;
|
||||
let file = directory.open_internal_file(name)?;
|
||||
#[cfg(unix)]
|
||||
let contention = {
|
||||
@@ -293,10 +395,20 @@ impl WorkspaceDriveLease {
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let contention: Option<std::io::Error> = None;
|
||||
if let Some(error) = contention {
|
||||
if !codewhale_util::lock::is_contention(&error) {
|
||||
return Err(error).context("Failed to lock workspace lease");
|
||||
if codewhale_util::lock::is_contention(&error) {
|
||||
return Ok(LeaseAttempt::Contended);
|
||||
}
|
||||
return Ok(None);
|
||||
// No advisory locks on this mount at all: two drivers cannot be
|
||||
// excluded here, but that is a property of the filesystem, not a
|
||||
// holder. `EWOULDBLOCK` was handled above and stays contention.
|
||||
#[cfg(unix)]
|
||||
if codewhale_util::lock::advisory_locking_unavailable(&error) {
|
||||
return Ok(LeaseAttempt::Unsupported(LeaseUnavailability::new(
|
||||
state_root,
|
||||
format!("this filesystem refuses advisory file locks ({error})"),
|
||||
)));
|
||||
}
|
||||
return Err(error).context("Failed to lock workspace lease");
|
||||
}
|
||||
#[cfg(any(unix, windows))]
|
||||
anyhow::ensure!(
|
||||
@@ -328,7 +440,7 @@ impl WorkspaceDriveLease {
|
||||
.set_len(0)
|
||||
.and_then(|()| lease.file.seek(SeekFrom::Start(0)).map(|_| ()))
|
||||
.and_then(|()| lease.file.write_all(holder.to_record().as_bytes()));
|
||||
Ok(Some(lease))
|
||||
Ok(LeaseAttempt::Acquired(lease))
|
||||
}
|
||||
|
||||
/// Read the recorded holder, if any. Missing or malformed data is `None`;
|
||||
@@ -340,7 +452,11 @@ impl WorkspaceDriveLease {
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("Workspace lease name is invalid")?;
|
||||
let directory = match PrivateDirectory::inspect(parent) {
|
||||
// Naming a holder is a read. On a mount that cannot express ownership
|
||||
// the strict proof fails for every directory, and refusing would hide
|
||||
// the holder's pid behind "record unreadable" on exactly the platform
|
||||
// where a user most needs to know who holds the workspace.
|
||||
let directory = match PrivateDirectory::inspect_trusting_mount(parent) {
|
||||
Ok(directory) => directory,
|
||||
Err(error)
|
||||
if error.chain().any(|cause| {
|
||||
@@ -457,9 +573,6 @@ fn current_process_start() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The base for lease files. Test isolation mirrors `snapshot::paths`: an
|
||||
/// unsealed test gets a private base, so a lease taken by a fixture never
|
||||
/// lands in the developer's real state dir.
|
||||
fn lease_state_base() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -497,15 +610,31 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn expect_held(admission: LeaseAdmission) -> SharedWorkspaceDriveLease {
|
||||
match admission {
|
||||
LeaseAdmission::Held(lease) => lease,
|
||||
LeaseAdmission::Contended => panic!("expected a held lease, got contention"),
|
||||
LeaseAdmission::Unsupported(unavailable) => {
|
||||
panic!("expected a held lease, got {unavailable}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_then_contend_reports_holder() {
|
||||
let workspace = temp_workspace("contend");
|
||||
let first =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("tui"), None).expect("first acquire");
|
||||
assert!(first.is_some(), "first drive is admitted");
|
||||
assert!(
|
||||
matches!(first, LeaseAttempt::Acquired(_)),
|
||||
"first drive is admitted"
|
||||
);
|
||||
let second = WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None)
|
||||
.expect("contend probe");
|
||||
assert!(second.is_none(), "second drive must be refused");
|
||||
assert!(
|
||||
matches!(second, LeaseAttempt::Contended),
|
||||
"second drive must be refused"
|
||||
);
|
||||
let holder = WorkspaceDriveLease::read_holder(&workspace)
|
||||
.expect("read holder")
|
||||
.expect("holder recorded");
|
||||
@@ -515,29 +644,38 @@ mod tests {
|
||||
drop(first);
|
||||
let third =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("vscode"), None).expect("reacquire");
|
||||
assert!(third.is_some(), "lease frees after drop");
|
||||
assert!(
|
||||
matches!(third, LeaseAttempt::Acquired(_)),
|
||||
"lease frees after drop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_lease_reenters_within_a_process() {
|
||||
let workspace = temp_workspace("shared");
|
||||
let first = SharedWorkspaceDriveLease::acquire(&workspace, Some("tui"), None)
|
||||
.expect("first shared acquire")
|
||||
.expect("lease taken");
|
||||
let first = expect_held(
|
||||
SharedWorkspaceDriveLease::acquire(&workspace, Some("tui"), None)
|
||||
.expect("first shared acquire"),
|
||||
);
|
||||
// A second engine in the same process joins by refcount instead of
|
||||
// refusing — the lease's unit is the process.
|
||||
let second = SharedWorkspaceDriveLease::acquire(&workspace, Some("vscode"), None)
|
||||
.expect("second shared acquire")
|
||||
.expect("same-process join");
|
||||
let second = expect_held(
|
||||
SharedWorkspaceDriveLease::acquire(&workspace, Some("vscode"), None)
|
||||
.expect("second shared acquire"),
|
||||
);
|
||||
drop(first);
|
||||
// Still held by the second handle: a raw probe must be refused.
|
||||
let refused =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("probe"), None).expect("probe");
|
||||
assert!(refused.is_none(), "lease still held while shared");
|
||||
assert!(
|
||||
matches!(refused, LeaseAttempt::Contended),
|
||||
"lease still held while shared"
|
||||
);
|
||||
drop(second);
|
||||
let after = SharedWorkspaceDriveLease::acquire(&workspace, Some("next"), None)
|
||||
.expect("reacquire after full release")
|
||||
.expect("lease freed after last handle");
|
||||
let after = expect_held(
|
||||
SharedWorkspaceDriveLease::acquire(&workspace, Some("next"), None)
|
||||
.expect("reacquire after full release"),
|
||||
);
|
||||
drop(after);
|
||||
}
|
||||
|
||||
@@ -553,9 +691,10 @@ mod tests {
|
||||
let workspace = workspace.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for _ in 0..25 {
|
||||
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("churn"), None)
|
||||
.expect("acquire")
|
||||
.expect("same-process join");
|
||||
let lease = expect_held(
|
||||
SharedWorkspaceDriveLease::acquire(&workspace, Some("churn"), None)
|
||||
.expect("acquire"),
|
||||
);
|
||||
drop(lease);
|
||||
}
|
||||
}));
|
||||
@@ -565,9 +704,10 @@ mod tests {
|
||||
}
|
||||
// Every handle is gone: the lease is free and a fresh holder records
|
||||
// itself rather than joining a slot nothing released.
|
||||
let lease = SharedWorkspaceDriveLease::acquire(&workspace, Some("after"), None)
|
||||
.expect("acquire after churn")
|
||||
.expect("the lease is free after the last handle");
|
||||
let lease = expect_held(
|
||||
SharedWorkspaceDriveLease::acquire(&workspace, Some("after"), None)
|
||||
.expect("acquire after churn"),
|
||||
);
|
||||
let holder = WorkspaceDriveLease::read_holder(&workspace)
|
||||
.expect("read holder")
|
||||
.expect("holder recorded");
|
||||
@@ -575,6 +715,45 @@ mod tests {
|
||||
drop(lease);
|
||||
}
|
||||
|
||||
/// The one branch that decides fatal from degraded. A mount with no
|
||||
/// advisory locks must never be reported as a second driver — that would
|
||||
/// keep the engine from starting over a property of the filesystem — and a
|
||||
/// second driver must never be downgraded to "run anyway", which would let
|
||||
/// two engines write one tree. The two predicates are disjoint by
|
||||
/// construction; this pins that.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_a_held_lock_is_contention_and_only_a_lockless_mount_is_unsupported() {
|
||||
use std::io::Error;
|
||||
|
||||
let held = Error::from_raw_os_error(libc::EWOULDBLOCK);
|
||||
assert!(
|
||||
codewhale_util::lock::is_contention(&held),
|
||||
"a held lock is contention"
|
||||
);
|
||||
assert!(
|
||||
!codewhale_util::lock::advisory_locking_unavailable(&held),
|
||||
"a held lock is not a mount without locks"
|
||||
);
|
||||
for code in [
|
||||
libc::EPERM,
|
||||
libc::EACCES,
|
||||
libc::ENOTSUP,
|
||||
libc::EINVAL,
|
||||
libc::ENOSYS,
|
||||
] {
|
||||
let absent = Error::from_raw_os_error(code);
|
||||
assert!(
|
||||
!codewhale_util::lock::is_contention(&absent),
|
||||
"{code} is the mount answering, not a holder"
|
||||
);
|
||||
assert!(
|
||||
codewhale_util::lock::advisory_locking_unavailable(&absent),
|
||||
"{code} means this mount has no advisory locks"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_path_is_stable_and_worktree_aware() {
|
||||
let workspace = temp_workspace("paths");
|
||||
@@ -679,21 +858,28 @@ mod tests {
|
||||
|
||||
let workspace = temp_workspace("dead");
|
||||
holder_of_a_reaped_child(&workspace);
|
||||
let first = WorkspaceDriveLease::try_acquire(&workspace, Some("next"), None)
|
||||
.expect("acquire")
|
||||
.expect("an unlocked lease remains acquirable despite a stale record");
|
||||
let first =
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("next"), None).expect("acquire");
|
||||
assert!(
|
||||
matches!(first, LeaseAttempt::Acquired(_)),
|
||||
"an unlocked lease remains acquirable despite a stale record"
|
||||
);
|
||||
holder_of_a_reaped_child(&workspace);
|
||||
assert!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("contender"), None)
|
||||
.expect("contend")
|
||||
.is_none(),
|
||||
matches!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("contender"), None)
|
||||
.expect("contend"),
|
||||
LeaseAttempt::Contended
|
||||
),
|
||||
"a stale holder record must not replace a held kernel lock"
|
||||
);
|
||||
drop(first);
|
||||
assert!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("after"), None)
|
||||
.expect("reacquire")
|
||||
.is_some(),
|
||||
matches!(
|
||||
WorkspaceDriveLease::try_acquire(&workspace, Some("after"), None)
|
||||
.expect("reacquire"),
|
||||
LeaseAttempt::Acquired(_)
|
||||
),
|
||||
"the same lease is available after its holder releases it"
|
||||
);
|
||||
}
|
||||
|
||||
+408
-14
@@ -4,7 +4,11 @@
|
||||
use crate::SecretsError;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, SecretsError> {
|
||||
pub(crate) fn open_private(
|
||||
path: &Path,
|
||||
create: bool,
|
||||
role: StoreFile,
|
||||
) -> Result<fs::File, SecretsError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() || !meta.is_file() => {
|
||||
return Err(std::io::Error::other("The secret store must be a regular file.").into());
|
||||
@@ -35,16 +39,9 @@ pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, Secret
|
||||
return Err(std::io::Error::other("The secret store must be a regular file.").into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(SecretsError::InsecurePermissions {
|
||||
path: path.into(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
require_private_mode(&file, path, role)?;
|
||||
#[cfg(not(unix))]
|
||||
let _ = role;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
@@ -57,6 +54,169 @@ pub(crate) fn open_private(path: &Path, create: bool) -> Result<fs::File, Secret
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Which of the store's two files is being opened.
|
||||
///
|
||||
/// The distinction decides whether a wider-than-`0600` mode is repaired or
|
||||
/// refused, and it is not cosmetic: one file carries the secret and the other
|
||||
/// carries nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StoreFile {
|
||||
/// `secrets.json`. A mode that let another user in means the bytes may no
|
||||
/// longer be ours, so the file is refused rather than quietly re-narrowed —
|
||||
/// repairing the mode would hide exactly the question the caller must
|
||||
/// answer. The refusal names the mode it saw.
|
||||
Content,
|
||||
/// The `<store>.lock` sibling. It holds no secret and exists only to
|
||||
/// serialise writers, so a wider mode is hygiene: it is narrowed in place
|
||||
/// before use, and only a filesystem that cannot hold modes is accepted as
|
||||
/// it is.
|
||||
Lock,
|
||||
}
|
||||
|
||||
/// Make sure a secret-store file is readable by its owner only.
|
||||
///
|
||||
/// 1. Already `0o600` or narrower: done, no syscall and no probe.
|
||||
/// 2. A lock file that is wider: narrow it through the handle that was opened
|
||||
/// and look again. A repair that does not take effect falls through.
|
||||
/// 3. Still wider, and the mount cannot apply Unix mode bits — HarmonyOS hmdfs
|
||||
/// reports the logged-in user as the owner of every object, so a process is
|
||||
/// never the owner of what it creates and `chmod` answers `EPERM`; FUSE and
|
||||
/// some network mounts refuse it too: accept with a warning, because
|
||||
/// refusing leaves that platform with no store at all and the fallback is a
|
||||
/// plaintext key in `config.toml`, a wider exposure.
|
||||
/// 4. Still wider on a filesystem that does store modes: a real exposure, and
|
||||
/// still refused.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn require_private_mode(
|
||||
file: &fs::File,
|
||||
path: &Path,
|
||||
role: StoreFile,
|
||||
) -> Result<(), SecretsError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let observed = file.metadata()?.permissions().mode() & 0o777;
|
||||
if observed & 0o077 == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
// Narrowing through the handle keeps it on the object that was opened, so a
|
||||
// substituted path cannot be the thing that gets changed. Only the lock is
|
||||
// repaired; see [`StoreFile`] for why the content is not.
|
||||
let narrowed = match role {
|
||||
StoreFile::Lock => file
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.and_then(|()| file.metadata())
|
||||
.map(|metadata| metadata.permissions().mode() & 0o777)
|
||||
.ok(),
|
||||
StoreFile::Content => None,
|
||||
};
|
||||
// The probe costs a create, a stat and an unlink, so it runs only when the
|
||||
// mode is still wide after any repair.
|
||||
let mount_cannot_hold_modes = match narrowed {
|
||||
Some(mode) if mode & 0o077 == 0 => false,
|
||||
_ => mount_ignores_unix_modes(path),
|
||||
};
|
||||
match classify_store_mode(observed, narrowed, mount_cannot_hold_modes) {
|
||||
StoreMode::Narrowed => Ok(()),
|
||||
StoreMode::Unavoidable { mode } => {
|
||||
tracing::warn!(
|
||||
"{} has mode {mode:o} and its filesystem does not apply Unix mode bits; \
|
||||
relying on the mount's own access control for the secret store",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
StoreMode::Exposed { mode } => Err(SecretsError::InsecurePermissions {
|
||||
path: path.into(),
|
||||
mode,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a store file's mode turned out to be worth, given what the mount did
|
||||
/// when asked for `0o600`.
|
||||
///
|
||||
/// Separated from the syscalls so every branch is testable: the interesting
|
||||
/// cases need a mount that either refuses `chmod` or accepts it without
|
||||
/// applying it, which no portable filesystem produces.
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StoreMode {
|
||||
/// Narrower than `0o600` after the repair.
|
||||
Narrowed,
|
||||
/// Wider, and this filesystem cannot represent the mode at all.
|
||||
Unavoidable { mode: u32 },
|
||||
/// Wider, on a filesystem that does store modes: still a refusal.
|
||||
Exposed { mode: u32 },
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn classify_store_mode(
|
||||
observed: u32,
|
||||
narrowed: Option<u32>,
|
||||
mount_cannot_hold_modes: bool,
|
||||
) -> StoreMode {
|
||||
if narrowed.is_some_and(|mode| mode & 0o077 == 0) {
|
||||
return StoreMode::Narrowed;
|
||||
}
|
||||
let mode = narrowed.unwrap_or(observed);
|
||||
if mount_cannot_hold_modes {
|
||||
StoreMode::Unavoidable { mode }
|
||||
} else {
|
||||
StoreMode::Exposed { mode }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path`'s directory refuses the mode a file is created with *and* the
|
||||
/// one it is then asked for.
|
||||
///
|
||||
/// Creates one probe file beside `path`, the same way the store creates its
|
||||
/// own, at `0o600`, and reads back the mode the mount stored. A filesystem that
|
||||
/// keeps mode bits reports `0o600` or narrower. A wider mode alone is not
|
||||
/// enough to blame the mount — a default ACL or a setgid parent widens a new
|
||||
/// file on a filesystem that stores modes perfectly well — so the probe asks
|
||||
/// the mount to narrow it and looks again; only a refusal, or a mode that stays
|
||||
/// wide, means the mode says nothing about who may read the file. Any failure
|
||||
/// to run the probe answers `false`, so a directory we cannot create files in
|
||||
/// keeps the strict check instead of widening it.
|
||||
///
|
||||
/// Only a store file whose mode is wider than `0o600` reaches this, so a
|
||||
/// filesystem that stores modes never pays for it; on the mounts that do reach
|
||||
/// it, the cost is one create, one stat, at most one `chmod` and one unlink.
|
||||
///
|
||||
/// The same rule, with the same reasoning, lives in
|
||||
/// `codewhale_config::private_directory::private_footing`. This crate is below
|
||||
/// `codewhale-config` in the graph, so it cannot call it; the probe is a dozen
|
||||
/// lines and the alternative is a dependency cycle. `scripts/ohos-secrets-store-smoke.sh`
|
||||
/// covers the synthesising branch end to end against a real mount; the tests
|
||||
/// here cover everything a portable filesystem can produce.
|
||||
#[cfg(unix)]
|
||||
fn mount_ignores_unix_modes(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(probe) = tempfile::Builder::new()
|
||||
.prefix(".codewhale-mode-probe-")
|
||||
.tempfile_in(parent)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let narrows = |file: &fs::File| {
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.is_ok_and(|()| {
|
||||
file.metadata()
|
||||
.is_ok_and(|meta| meta.permissions().mode() & 0o077 == 0)
|
||||
})
|
||||
};
|
||||
match probe.as_file().metadata() {
|
||||
// `0o077` clear means the mount stored the `0o600` we created with.
|
||||
Ok(meta) if meta.permissions().mode() & 0o077 == 0 => false,
|
||||
Ok(_) => !narrows(probe.as_file()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_write_lock<T>(
|
||||
path: &Path,
|
||||
operation: impl FnOnce(&Path) -> Result<T, SecretsError>,
|
||||
@@ -79,7 +239,241 @@ pub(crate) fn with_write_lock<T>(
|
||||
let path = parent.canonicalize()?.join(filename);
|
||||
let mut lock_name = path.as_os_str().to_os_string();
|
||||
lock_name.push(".lock");
|
||||
let mut lock = fd_lock::RwLock::new(open_private(Path::new(&lock_name), true)?);
|
||||
let _guard = lock.write()?;
|
||||
operation(&path)
|
||||
let mut lock =
|
||||
fd_lock::RwLock::new(open_private(Path::new(&lock_name), true, StoreFile::Lock)?);
|
||||
match lock.write() {
|
||||
Ok(_guard) => operation(&path),
|
||||
// HarmonyOS hmdfs refuses `flock` and `fcntl(F_SETLK)` with EPERM for
|
||||
// every file, so the cross-process guard cannot be taken there. Fall
|
||||
// back to serialising this process's own writers and say so: refusing
|
||||
// outright would leave no way to store a credential on that mount at
|
||||
// all. The tradeoff is real and narrow — two Codewhale processes whose
|
||||
// snapshots of `secrets.json` overlap can lose one of the two writes,
|
||||
// which needs both to rewrite the same store in the same instant.
|
||||
Err(error) if advisory_locking_unavailable(&error) => {
|
||||
tracing::warn!(
|
||||
"{} cannot take a file lock ({error}); serialising secret-store writes \
|
||||
within this process only",
|
||||
lock_name.to_string_lossy()
|
||||
);
|
||||
let _guard = in_process_write_guard();
|
||||
operation(&path)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed advisory lock means the filesystem has none at all, as
|
||||
/// opposed to a lock that is genuinely held by someone else.
|
||||
///
|
||||
/// `EWOULDBLOCK` is deliberately absent: a held lock must still wait or fail
|
||||
/// rather than fall back. The rest are the mount saying "no locking here".
|
||||
#[cfg(unix)]
|
||||
fn advisory_locking_unavailable(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.raw_os_error(),
|
||||
Some(libc::EPERM)
|
||||
| Some(libc::EACCES)
|
||||
| Some(libc::ENOTSUP)
|
||||
| Some(libc::EINVAL)
|
||||
| Some(libc::ENOSYS)
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows always offers `LockFileEx` through `fd-lock`, so a failed write
|
||||
/// lock there is genuine contention, never an absent advisory lock.
|
||||
#[cfg(not(unix))]
|
||||
fn advisory_locking_unavailable(_error: &std::io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Serialises secret-store writers inside this process when the filesystem
|
||||
/// offers no advisory lock. Coarse on purpose: store writes are rare and short.
|
||||
fn in_process_write_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
WRITE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Put a store file on disk with exactly `mode`, the way a user running
|
||||
/// `chmod` on an existing `secrets.json` leaves it.
|
||||
fn store_at(dir: &Path, mode: u32) -> PathBuf {
|
||||
let path = dir.join("secrets.json");
|
||||
fs::write(&path, "{\"entries\":{}}").unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
mode,
|
||||
"the test filesystem did not store {mode:o} verbatim"
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
fn names_in(dir: &Path) -> Vec<String> {
|
||||
fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_store_opens() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = store_at(tmp.path(), 0o600);
|
||||
open_private(&path, false, StoreFile::Content).expect("an 0600 store must open");
|
||||
assert_eq!(
|
||||
names_in(tmp.path()),
|
||||
vec!["secrets.json".to_string()],
|
||||
"an 0600 store must be accepted without writing a probe file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_created_on_first_write_is_owner_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("secrets.json");
|
||||
open_private(&path, true, StoreFile::Content).expect("create must succeed");
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600,
|
||||
"a freshly created store must be owner-only"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every wider mode on a filesystem that stores mode bits is a real
|
||||
/// exposure and must stay refused. The probe runs here for real, against
|
||||
/// this directory, and has to answer "this mount stores modes".
|
||||
#[test]
|
||||
fn wider_store_is_refused_with_its_observed_mode() {
|
||||
for mode in [0o660, 0o640, 0o644, 0o666, 0o606] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = store_at(tmp.path(), mode);
|
||||
let err = open_private(&path, false, StoreFile::Content).unwrap_err();
|
||||
match err {
|
||||
SecretsError::InsecurePermissions {
|
||||
path: seen,
|
||||
mode: seen_mode,
|
||||
} => {
|
||||
assert_eq!(seen, path);
|
||||
assert_eq!(seen_mode, mode);
|
||||
}
|
||||
other => panic!("mode {mode:o} must be refused; got: {other}"),
|
||||
}
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
mode,
|
||||
"a refused store is left exactly as it was found"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock file carries no secret, so a wide mode on it is hygiene rather
|
||||
/// than evidence, and the "narrow what can be narrowed" rule applies: the
|
||||
/// store opens and the file ends up `0600`. This is the case a user hits
|
||||
/// after restoring a backup, and the one whose refusal read as "your secret
|
||||
/// backend is broken".
|
||||
#[test]
|
||||
fn wide_lock_file_is_repaired_in_place() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lock = tmp.path().join("secrets.json.lock");
|
||||
fs::write(&lock, b"").unwrap();
|
||||
fs::set_permissions(&lock, fs::Permissions::from_mode(0o660)).unwrap();
|
||||
open_private(&lock, false, StoreFile::Lock)
|
||||
.expect("a wide lock must be repaired, not refused");
|
||||
assert_eq!(
|
||||
fs::metadata(&lock).unwrap().permissions().mode() & 0o777,
|
||||
0o600,
|
||||
"the repair must actually be applied"
|
||||
);
|
||||
assert_eq!(
|
||||
names_in(tmp.path()),
|
||||
vec!["secrets.json.lock".to_string()],
|
||||
"a repair must not need the probe"
|
||||
);
|
||||
}
|
||||
|
||||
/// The classification is the whole decision, and the cases that matter need
|
||||
/// a mount that refuses `chmod` or accepts it without applying it, which no
|
||||
/// portable
|
||||
/// filesystem produces. They are pinned here instead.
|
||||
#[test]
|
||||
fn store_mode_classification_covers_every_outcome() {
|
||||
// The mount stored the narrowing we asked for.
|
||||
assert_eq!(
|
||||
classify_store_mode(0o660, Some(0o600), false),
|
||||
StoreMode::Narrowed
|
||||
);
|
||||
// hmdfs: the call is accepted and the mount keeps its own mode.
|
||||
assert_eq!(
|
||||
classify_store_mode(0o660, Some(0o660), true),
|
||||
StoreMode::Unavoidable { mode: 0o660 }
|
||||
);
|
||||
// hmdfs/POSIX-less mount: the call is refused.
|
||||
assert_eq!(
|
||||
classify_store_mode(0o660, None, true),
|
||||
StoreMode::Unavoidable { mode: 0o660 }
|
||||
);
|
||||
// A mode-storing mount that accepted the call and ignored it is a real
|
||||
// exposure, and the observed mode is what the caller is told about.
|
||||
assert_eq!(
|
||||
classify_store_mode(0o660, Some(0o660), false),
|
||||
StoreMode::Exposed { mode: 0o660 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify_store_mode(0o660, None, false),
|
||||
StoreMode::Exposed { mode: 0o660 }
|
||||
);
|
||||
// A successful narrowing to something still wider is not a repair.
|
||||
assert_eq!(
|
||||
classify_store_mode(0o666, Some(0o660), false),
|
||||
StoreMode::Exposed { mode: 0o660 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_a_mode_storing_filesystem() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
!mount_ignores_unix_modes(&tmp.path().join("secrets.json")),
|
||||
"a local filesystem stores the 0600 a file is created with"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_leaves_nothing_behind() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let before = names_in(tmp.path());
|
||||
assert!(!mount_ignores_unix_modes(&tmp.path().join("secrets.json")));
|
||||
assert_eq!(names_in(tmp.path()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_answers_false_without_a_parent_directory() {
|
||||
assert!(!mount_ignores_unix_modes(Path::new("secrets.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_lock_is_not_mistaken_for_a_missing_one() {
|
||||
// EWOULDBLOCK is what a lock held by another process reports; treating
|
||||
// it as "no locking on this mount" would silently drop serialisation.
|
||||
assert!(!advisory_locking_unavailable(
|
||||
&std::io::Error::from_raw_os_error(libc::EWOULDBLOCK)
|
||||
));
|
||||
assert!(!advisory_locking_unavailable(
|
||||
&std::io::Error::from_raw_os_error(libc::EINTR)
|
||||
));
|
||||
for errno in [libc::EPERM, libc::EACCES, libc::ENOTSUP, libc::EINVAL] {
|
||||
assert!(
|
||||
advisory_locking_unavailable(&std::io::Error::from_raw_os_error(errno)),
|
||||
"errno {errno} means the filesystem has no advisory locks"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -623,13 +623,14 @@ impl FileKeyringStore {
|
||||
|
||||
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
|
||||
use std::io::Read as _;
|
||||
let mut file = match file_lock::open_private(&self.path, false) {
|
||||
Ok(file) => file,
|
||||
Err(SecretsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut file =
|
||||
match file_lock::open_private(&self.path, false, file_lock::StoreFile::Content) {
|
||||
Ok(file) => file,
|
||||
Err(SecretsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut raw = String::new();
|
||||
file.read_to_string(&mut raw)?;
|
||||
if raw.trim().is_empty() {
|
||||
@@ -760,14 +761,12 @@ fn atomic_write_private_file(path: &Path, body: &[u8]) -> Result<(), SecretsErro
|
||||
tmp.write_all(body).map_err(SecretsError::Io)?;
|
||||
tmp.flush().map_err(SecretsError::Io)?;
|
||||
tmp.as_file().sync_all().map_err(SecretsError::Io)?;
|
||||
// The temp file is created at 0o600, so this is not about narrowing it: a
|
||||
// mount can apply a mode of its own to a new file, and publishing contents
|
||||
// under one is exactly what the store's own gate exists to prevent. The
|
||||
// same rule that admits the store admits the file about to replace it.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o600);
|
||||
tmp.as_file()
|
||||
.set_permissions(perms)
|
||||
.map_err(SecretsError::Io)?;
|
||||
}
|
||||
file_lock::require_private_mode(tmp.as_file(), path, file_lock::StoreFile::Content)?;
|
||||
tmp.persist(path).map_err(|e| SecretsError::Io(e.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -2021,12 +2022,13 @@ impl SessionManager {
|
||||
let paths = self.late_usage_paths(session_id)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
|
||||
OpenOptions::new()
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let handle = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(&dir)?
|
||||
.set_permissions(fs::Permissions::from_mode(0o700))?;
|
||||
.open(&dir)?;
|
||||
codewhale_config::private_directory::tighten_mode_of(&handle, &dir, 0o700)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
@@ -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