feat: add client-hosted external workdirs
This commit is contained in:
Generated
+5
@@ -1446,6 +1446,7 @@ dependencies = [
|
|||||||
"grep-regex",
|
"grep-regex",
|
||||||
"grep-searcher",
|
"grep-searcher",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -6258,9 +6259,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"client",
|
"client",
|
||||||
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
"project-record",
|
"project-record",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -6270,9 +6273,11 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"ticket",
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml",
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::fmt;
|
|||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
|
||||||
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
||||||
const MAX_REDIRECTS: usize = 10;
|
const MAX_REDIRECTS: usize = 10;
|
||||||
@@ -173,6 +174,37 @@ impl BackendApiClient {
|
|||||||
.bearer_auth(&self.access_token.0))
|
.bearer_auth(&self.access_token.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn websocket_request(
|
||||||
|
&self,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, BackendApiClientError> {
|
||||||
|
let mut url = self.origin.url(path_and_query)?;
|
||||||
|
let scheme = match url.scheme() {
|
||||||
|
"http" => "ws",
|
||||||
|
"https" => "wss",
|
||||||
|
_ => unreachable!("Backend origin validates HTTP schemes"),
|
||||||
|
};
|
||||||
|
url.set_scheme(scheme).map_err(|()| {
|
||||||
|
BackendApiClientError::InvalidRequestPath(
|
||||||
|
"Backend WebSocket URL scheme could not be constructed".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut request = url.as_str().into_client_request().map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidRequestPath(format!(
|
||||||
|
"Backend WebSocket request is invalid: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
request.headers_mut().insert(
|
||||||
|
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
|
||||||
|
self.authorization_header_value().parse().map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidRequestPath(format!(
|
||||||
|
"Backend authorization header is invalid: {error}"
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn blocking_request(
|
pub fn blocking_request(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
@@ -743,6 +775,33 @@ mod tests {
|
|||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_requests_are_same_origin_and_use_the_stored_bearer() {
|
||||||
|
let client = BackendApiClient::from_access_token_for_test(
|
||||||
|
"https://backend.example:8443",
|
||||||
|
"websocket-secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = client
|
||||||
|
.websocket_request("/api/w/workspace/external-workdir-grants/grant/provider")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(request.uri().scheme_str(), Some("wss"));
|
||||||
|
assert_eq!(request.uri().host(), Some("backend.example"));
|
||||||
|
assert_eq!(request.uri().port_u16(), Some(8443));
|
||||||
|
assert_eq!(
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
|
||||||
|
.unwrap(),
|
||||||
|
"Bearer websocket-secret"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.websocket_request("//other.example/provider")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_debug_and_errors_never_include_token_value() {
|
fn client_debug_and_errors_never_include_token_value() {
|
||||||
let client = BackendApiClient::from_access_token_for_test(
|
let client = BackendApiClient::from_access_token_for_test(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub use server_api::{
|
|||||||
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
|
||||||
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
||||||
|
WorkingDirectorySource as BackendWorkingDirectorySource,
|
||||||
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -1018,7 +1019,7 @@ mod tests {
|
|||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory": {
|
"working_directory": {
|
||||||
"working_directory_id": "wd-1",
|
"working_directory_id": "wd-1",
|
||||||
"repository_key": "main",
|
"source": {"kind": "repository", "repository_key": "main"},
|
||||||
"materializer_kind": "runtime_git_clone",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"occupied_by": {
|
"occupied_by": {
|
||||||
@@ -1035,7 +1036,12 @@ mod tests {
|
|||||||
let attachment = worker.workdir_attachments.into_iter().next().unwrap();
|
let attachment = worker.workdir_attachments.into_iter().next().unwrap();
|
||||||
assert_eq!(attachment.alias, "checkout");
|
assert_eq!(attachment.alias, "checkout");
|
||||||
let workdir = attachment.working_directory;
|
let workdir = attachment.working_directory;
|
||||||
assert_eq!(workdir.repository_key, "main");
|
assert_eq!(
|
||||||
|
workdir.source,
|
||||||
|
BackendWorkingDirectorySource::Repository {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
|
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
|
||||||
assert_eq!(occupied_by.runtime_id, "arcadia");
|
assert_eq!(occupied_by.runtime_id, "arcadia");
|
||||||
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ pub use backend_runtime::{
|
|||||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||||
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
|
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
|
||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary,
|
||||||
create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers,
|
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
|
||||||
list_backend_workers, observe_backend_worker_session, restore_backend_worker,
|
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
|
||||||
|
restore_backend_worker,
|
||||||
};
|
};
|
||||||
pub use backend_workspace::{
|
pub use backend_workspace::{
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ grep-matcher = "0.1.8"
|
|||||||
grep-regex = "0.1.14"
|
grep-regex = "0.1.14"
|
||||||
grep-searcher = "0.1.16"
|
grep-searcher = "0.1.16"
|
||||||
ignore = "0.4.25"
|
ignore = "0.4.25"
|
||||||
|
libc = "0.2"
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -26,15 +26,31 @@ pub fn run_glob(
|
|||||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||||
.compile_matcher();
|
.compile_matcher();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
|
let mut retained_path_bytes = 0_usize;
|
||||||
|
let mut provider_truncated = false;
|
||||||
let mut walker = WalkBuilder::new(base);
|
let mut walker = WalkBuilder::new(base);
|
||||||
walker.hidden(false).follow_links(false);
|
walker.hidden(false).follow_links(false);
|
||||||
|
let mut visited = 0_usize;
|
||||||
for entry in walker.build().flatten() {
|
for entry in walker.build().flatten() {
|
||||||
|
visited = visited.saturating_add(1);
|
||||||
|
if visited > crate::MAX_TRAVERSAL_ENTRIES {
|
||||||
|
return Err(FsError::InvalidArgument(format!(
|
||||||
|
"glob traversal exceeds provider limit {}",
|
||||||
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
|
)));
|
||||||
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let readable = resolve_access_path(path)
|
let readable = resolve_access_path(path)
|
||||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
||||||
if !path.is_file() || !readable {
|
if !path.is_file() || !readable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let Ok(file) = access.open_read_file(path, path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||||
if !matcher.is_match(relative) {
|
if !matcher.is_match(relative) {
|
||||||
continue;
|
continue;
|
||||||
@@ -42,10 +58,17 @@ pub fn run_glob(
|
|||||||
let logical = path.strip_prefix(root).map_err(|_| {
|
let logical = path.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||||
})?;
|
})?;
|
||||||
matches.push(FsPath::new(logical.to_string_lossy())?);
|
let result_path = FsPath::new(logical.to_string_lossy())?;
|
||||||
|
let retained = result_path.as_str().len().saturating_add(16);
|
||||||
|
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||||
|
provider_truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
retained_path_bytes = retained_path_bytes.saturating_add(retained);
|
||||||
|
matches.push(result_path);
|
||||||
}
|
}
|
||||||
matches.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
matches.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
let truncated = matches.len() > request.limit;
|
let truncated = provider_truncated || matches.len() > request.limit;
|
||||||
matches.truncate(request.limit);
|
matches.truncate(request.limit);
|
||||||
Ok(GlobResult {
|
Ok(GlobResult {
|
||||||
paths: matches,
|
paths: matches,
|
||||||
|
|||||||
@@ -14,15 +14,167 @@ use std::path::{Path, PathBuf};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use glob::run_glob;
|
pub use glob::run_glob;
|
||||||
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
|
pub use local::{
|
||||||
|
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write,
|
||||||
|
};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
pub use search::run_grep;
|
pub use search::run_grep;
|
||||||
|
|
||||||
|
/// Provider-side ceiling for directory/tree traversal, independent of result truncation.
|
||||||
|
pub const MAX_TRAVERSAL_ENTRIES: usize = 100_000;
|
||||||
|
/// Provider-side ceiling for aggregate retained result paths per operation.
|
||||||
|
pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
|
||||||
|
/// Provider-side ceiling for aggregate grep source bytes per operation.
|
||||||
|
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Open `path` beneath `root` without following any symbolic link. Linux uses
|
||||||
|
/// `openat2` so resolution and open are one kernel-enforced operation. Other
|
||||||
|
/// platforms fail closed rather than silently weakening an External grant.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn open_root_no_symlinks(root: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::fd::{FromRawFd, RawFd};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let root = CString::new(root.as_os_str().as_bytes())
|
||||||
|
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "root contains NUL"))?;
|
||||||
|
// SAFETY: `root` is a valid C string and the returned descriptor is checked
|
||||||
|
// before ownership is transferred to File.
|
||||||
|
let fd: RawFd = unsafe {
|
||||||
|
libc::open(
|
||||||
|
root.as_ptr(),
|
||||||
|
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: fd is a new owned descriptor returned by open.
|
||||||
|
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn open_root_no_symlinks(_root: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"root-confined no-symlink open is unavailable on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn open_beneath_no_symlinks_at(
|
||||||
|
root: &std::fs::File,
|
||||||
|
relative: &Path,
|
||||||
|
) -> std::io::Result<std::fs::File> {
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct OpenHow {
|
||||||
|
flags: u64,
|
||||||
|
mode: u64,
|
||||||
|
resolve: u64,
|
||||||
|
}
|
||||||
|
const RESOLVE_NO_XDEV: u64 = 0x01;
|
||||||
|
const RESOLVE_NO_SYMLINKS: u64 = 0x04;
|
||||||
|
const RESOLVE_BENEATH: u64 = 0x08;
|
||||||
|
|
||||||
|
if relative.is_absolute() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"path is outside provider root",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let relative = if relative.as_os_str().is_empty() {
|
||||||
|
CString::new(".").expect("static path")
|
||||||
|
} else {
|
||||||
|
CString::new(relative.as_os_str().as_bytes()).map_err(|_| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
let how = OpenHow {
|
||||||
|
flags: (libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) as u64,
|
||||||
|
mode: 0,
|
||||||
|
resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV,
|
||||||
|
};
|
||||||
|
// SAFETY: `how` and the path remain alive for the syscall duration, and the
|
||||||
|
// borrowed root descriptor remains valid for the call.
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_openat2,
|
||||||
|
root.as_raw_fd(),
|
||||||
|
relative.as_ptr(),
|
||||||
|
&how as *const OpenHow,
|
||||||
|
std::mem::size_of::<OpenHow>(),
|
||||||
|
) as RawFd
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: fd is a new owned descriptor returned by openat2.
|
||||||
|
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn open_beneath_no_symlinks_at(
|
||||||
|
_root: &std::fs::File,
|
||||||
|
_relative: &Path,
|
||||||
|
) -> std::io::Result<std::fs::File> {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"root-confined no-symlink open is unavailable on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
let relative = path.strip_prefix(root).map_err(|_| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"path is outside provider root",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let root = open_root_no_symlinks(root)?;
|
||||||
|
open_beneath_no_symlinks_at(&root, relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"root-confined no-symlink open is unavailable on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Provider-owned access policy used by local filesystem operations.
|
/// Provider-owned access policy used by local filesystem operations.
|
||||||
pub trait FsAccessPolicy: Send + Sync {
|
pub trait FsAccessPolicy: Send + Sync {
|
||||||
fn is_readable(&self, path: &Path) -> bool;
|
fn is_readable(&self, path: &Path) -> bool;
|
||||||
fn is_writable(&self, path: &Path) -> bool;
|
fn is_writable(&self, path: &Path) -> bool;
|
||||||
|
|
||||||
|
/// Open an already-authorized readable file. Capability providers override
|
||||||
|
/// this to bind path resolution and open into one root-confined operation.
|
||||||
|
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
std::fs::File::open(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtain metadata for an already-authorized path. Capability providers
|
||||||
|
/// override this to prevent a path swap between authorization and stat.
|
||||||
|
fn read_metadata(
|
||||||
|
&self,
|
||||||
|
logical: &Path,
|
||||||
|
_resolved: &Path,
|
||||||
|
) -> std::io::Result<std::fs::Metadata> {
|
||||||
|
std::fs::symlink_metadata(logical)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an already-authorized directory for bounded enumeration.
|
||||||
|
/// Capability providers override this to bind traversal to a confined
|
||||||
|
/// directory descriptor rather than reopening a mutable path.
|
||||||
|
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||||
|
std::fs::read_dir(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
/// Authorize both the Workdir-visible path and its provider-resolved
|
/// Authorize both the Workdir-visible path and its provider-resolved
|
||||||
/// target. Implementations that do not distinguish symbolic-link identity
|
/// target. Implementations that do not distinguish symbolic-link identity
|
||||||
/// retain resolved-target semantics through the defaults.
|
/// retain resolved-target semantics through the defaults.
|
||||||
@@ -201,6 +353,165 @@ mod tests {
|
|||||||
assert!(serde_json::from_str::<FsPath>(r#""../secret""#).is_err());
|
assert!(serde_json::from_str::<FsPath>(r#""../secret""#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn root_confined_open_rejects_a_symlink_swap_between_authorization_and_open() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
struct SwapAccess {
|
||||||
|
root: PathBuf,
|
||||||
|
selected: PathBuf,
|
||||||
|
outside: PathBuf,
|
||||||
|
swapped: AtomicBool,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for SwapAccess {
|
||||||
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
|
path.starts_with(&self.root)
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _path: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn is_readable_paths(&self, _logical: &Path, _resolved: &Path) -> bool {
|
||||||
|
if !self.swapped.swap(true, Ordering::SeqCst) {
|
||||||
|
std::fs::remove_file(&self.selected).unwrap();
|
||||||
|
symlink(&self.outside, &self.selected).unwrap();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn open_read_file(
|
||||||
|
&self,
|
||||||
|
_logical: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<std::fs::File> {
|
||||||
|
open_beneath_no_symlinks(&self.root, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_dir = tempfile::tempdir().unwrap();
|
||||||
|
let outside_dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = root_dir.path().canonicalize().unwrap();
|
||||||
|
let outside = outside_dir.path().join("secret.txt");
|
||||||
|
let selected = root.join("selected.txt");
|
||||||
|
std::fs::write(&selected, "safe").unwrap();
|
||||||
|
std::fs::write(&outside, "secret").unwrap();
|
||||||
|
let access = SwapAccess {
|
||||||
|
root: root.clone(),
|
||||||
|
selected,
|
||||||
|
outside,
|
||||||
|
swapped: AtomicBool::new(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = run_read_bounded(
|
||||||
|
&root,
|
||||||
|
ReadRequest {
|
||||||
|
path: FsPath::new("selected.txt").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
&access,
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
)
|
||||||
|
.expect_err("openat2 must reject the swapped symbolic link");
|
||||||
|
assert!(matches!(error, FsError::Io { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn root_confined_stat_and_list_reject_swaps_after_authorization() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
struct SwapAccess {
|
||||||
|
root: PathBuf,
|
||||||
|
selected: PathBuf,
|
||||||
|
outside: PathBuf,
|
||||||
|
directory: bool,
|
||||||
|
swapped: AtomicBool,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for SwapAccess {
|
||||||
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
|
path.starts_with(&self.root)
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _path: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn is_readable_paths(&self, logical: &Path, _resolved: &Path) -> bool {
|
||||||
|
if logical == self.selected && !self.swapped.swap(true, Ordering::SeqCst) {
|
||||||
|
if self.directory {
|
||||||
|
std::fs::remove_dir(&self.selected).unwrap();
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&self.selected).unwrap();
|
||||||
|
}
|
||||||
|
symlink(&self.outside, &self.selected).unwrap();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn read_metadata(
|
||||||
|
&self,
|
||||||
|
_logical: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<std::fs::Metadata> {
|
||||||
|
open_beneath_no_symlinks(&self.root, resolved)?.metadata()
|
||||||
|
}
|
||||||
|
fn open_read_dir(
|
||||||
|
&self,
|
||||||
|
_logical: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<std::fs::ReadDir> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
|
||||||
|
std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_dir = tempfile::tempdir().unwrap();
|
||||||
|
let outside_dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = root_dir.path().canonicalize().unwrap();
|
||||||
|
let selected_file = root.join("selected.txt");
|
||||||
|
let outside_file = outside_dir.path().join("secret.txt");
|
||||||
|
std::fs::write(&selected_file, "safe").unwrap();
|
||||||
|
std::fs::write(&outside_file, "secret").unwrap();
|
||||||
|
let stat_error = run_stat(
|
||||||
|
&root,
|
||||||
|
StatRequest {
|
||||||
|
path: FsPath::new("selected.txt").unwrap(),
|
||||||
|
},
|
||||||
|
&SwapAccess {
|
||||||
|
root: root.clone(),
|
||||||
|
selected: selected_file,
|
||||||
|
outside: outside_file,
|
||||||
|
directory: false,
|
||||||
|
swapped: AtomicBool::new(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("confined stat must reject a swapped symlink");
|
||||||
|
assert!(matches!(stat_error, FsError::Io { .. }));
|
||||||
|
|
||||||
|
let selected_dir = root.join("selected-dir");
|
||||||
|
let outside_content = outside_dir.path().join("outside-dir");
|
||||||
|
std::fs::create_dir(&selected_dir).unwrap();
|
||||||
|
std::fs::create_dir(&outside_content).unwrap();
|
||||||
|
std::fs::write(outside_content.join("secret.txt"), "secret").unwrap();
|
||||||
|
let list_error = run_list(
|
||||||
|
&root,
|
||||||
|
ListRequest {
|
||||||
|
path: FsPath::new("selected-dir").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
&SwapAccess {
|
||||||
|
root: root.clone(),
|
||||||
|
selected: selected_dir,
|
||||||
|
outside: outside_content,
|
||||||
|
directory: true,
|
||||||
|
swapped: AtomicBool::new(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("confined list must reject a swapped symlink");
|
||||||
|
assert!(matches!(list_error, FsError::Io { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn direct_operations_cover_stat_read_write_edit_and_list() {
|
fn direct_operations_cover_stat_read_write_edit_and_list() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::{BufReader, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, FsPath, ListEntry,
|
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError,
|
||||||
ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest,
|
FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||||
WriteResult, direct_symlink,
|
WriteRequest, WriteResult, direct_symlink,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const READ_BUFFER_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
/// Execute stat while keeping host paths inside the provider boundary.
|
/// Execute stat while keeping host paths inside the provider boundary.
|
||||||
pub fn run_stat(
|
pub fn run_stat(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
@@ -23,7 +25,9 @@ pub fn run_stat(
|
|||||||
if !access.is_readable_paths(&path, &resolved) {
|
if !access.is_readable_paths(&path, &resolved) {
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
||||||
}
|
}
|
||||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = access
|
||||||
|
.read_metadata(&path, &resolved)
|
||||||
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
let kind = if metadata.file_type().is_symlink() {
|
let kind = if metadata.file_type().is_symlink() {
|
||||||
EntryKind::Symlink
|
EntryKind::Symlink
|
||||||
} else if metadata.is_file() {
|
} else if metadata.is_file() {
|
||||||
@@ -44,20 +48,110 @@ pub fn run_read(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
request: ReadRequest,
|
request: ReadRequest,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
|
) -> Result<ReadResult, FsError> {
|
||||||
|
run_read_with_limits(root, request, access, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a read while enforcing provider-owned source and response bounds.
|
||||||
|
///
|
||||||
|
/// The source is streamed through a fixed-size buffer. Files larger than the
|
||||||
|
/// configured source bound fail before their content is read, and growth after
|
||||||
|
/// the metadata check is fenced while streaming.
|
||||||
|
pub fn run_read_bounded(
|
||||||
|
root: &Path,
|
||||||
|
request: ReadRequest,
|
||||||
|
access: &dyn FsAccessPolicy,
|
||||||
|
limits: BoundedReadLimits,
|
||||||
|
) -> Result<ReadResult, FsError> {
|
||||||
|
run_read_with_limits(root, request, access, Some(limits))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_read_with_limits(
|
||||||
|
root: &Path,
|
||||||
|
request: ReadRequest,
|
||||||
|
access: &dyn FsAccessPolicy,
|
||||||
|
limits: Option<BoundedReadLimits>,
|
||||||
) -> Result<ReadResult, FsError> {
|
) -> Result<ReadResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let path = require_access(&path, &logical, access, false, false)?;
|
let target = require_access(&path, &logical, access, false, false)?;
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let file = access
|
||||||
|
.open_read_file(&path, &target)
|
||||||
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
|
let metadata = file.metadata().map_err(|error| map_io(&logical, error))?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||||
}
|
}
|
||||||
let bytes = fs::read(&path).map_err(|error| map_io(&logical, error))?;
|
if !metadata.is_file() {
|
||||||
let content_hash = hash_bytes(&bytes);
|
return Err(FsError::InvalidArgument(
|
||||||
let lines = bytes
|
"read source must be a regular file".to_string(),
|
||||||
.split_inclusive(|byte| *byte == b'\n')
|
));
|
||||||
.collect::<Vec<_>>();
|
}
|
||||||
let total_lines = lines.len();
|
if let Some(limits) = limits
|
||||||
|
&& metadata.len() > limits.max_source_bytes
|
||||||
|
{
|
||||||
|
return Err(FsError::InvalidArgument(format!(
|
||||||
|
"read source size {} exceeds provider limit {}",
|
||||||
|
metadata.len(),
|
||||||
|
limits.max_source_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_limit = limits
|
||||||
|
.map(|limits| request.max_bytes.min(limits.max_response_bytes))
|
||||||
|
.unwrap_or(request.max_bytes);
|
||||||
|
let retained_limit = response_limit.saturating_add(4);
|
||||||
|
let selected_end = request.offset.saturating_add(request.limit);
|
||||||
|
let mut selected = Vec::with_capacity(retained_limit.min(READ_BUFFER_BYTES));
|
||||||
|
let mut selected_bytes_seen = 0usize;
|
||||||
|
let mut current_line = 0usize;
|
||||||
|
let mut source_bytes_seen = 0u64;
|
||||||
|
let mut last_byte = None;
|
||||||
|
let mut content_hasher = Sha256::new();
|
||||||
|
let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file);
|
||||||
|
let mut buffer = [0u8; READ_BUFFER_BYTES];
|
||||||
|
loop {
|
||||||
|
let read_limit = limits
|
||||||
|
.map(|limits| {
|
||||||
|
limits
|
||||||
|
.max_source_bytes
|
||||||
|
.saturating_sub(source_bytes_seen)
|
||||||
|
.saturating_add(1)
|
||||||
|
.min(READ_BUFFER_BYTES as u64) as usize
|
||||||
|
})
|
||||||
|
.unwrap_or(READ_BUFFER_BYTES);
|
||||||
|
let read = reader
|
||||||
|
.read(&mut buffer[..read_limit])
|
||||||
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
source_bytes_seen = source_bytes_seen.saturating_add(read as u64);
|
||||||
|
if let Some(limits) = limits
|
||||||
|
&& source_bytes_seen > limits.max_source_bytes
|
||||||
|
{
|
||||||
|
return Err(FsError::InvalidArgument(format!(
|
||||||
|
"read source exceeds provider limit {}",
|
||||||
|
limits.max_source_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
content_hasher.update(&buffer[..read]);
|
||||||
|
for byte in &buffer[..read] {
|
||||||
|
if current_line >= request.offset && current_line < selected_end {
|
||||||
|
selected_bytes_seen = selected_bytes_seen.saturating_add(1);
|
||||||
|
if selected.len() < retained_limit {
|
||||||
|
selected.push(*byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *byte == b'\n' {
|
||||||
|
current_line = current_line.saturating_add(1);
|
||||||
|
}
|
||||||
|
last_byte = Some(*byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_lines =
|
||||||
|
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
|
||||||
if request.offset > total_lines && request.offset != 0 {
|
if request.offset > total_lines && request.offset != 0 {
|
||||||
return Err(FsError::InvalidArgument(format!(
|
return Err(FsError::InvalidArgument(format!(
|
||||||
"offset {} exceeds file length {total_lines}",
|
"offset {} exceeds file length {total_lines}",
|
||||||
@@ -68,16 +162,14 @@ pub fn run_read(
|
|||||||
.offset
|
.offset
|
||||||
.saturating_add(request.limit)
|
.saturating_add(request.limit)
|
||||||
.min(total_lines);
|
.min(total_lines);
|
||||||
let mut selected = lines[request.offset.min(total_lines)..end]
|
let byte_truncated = selected_bytes_seen > response_limit;
|
||||||
.iter()
|
|
||||||
.flat_map(|line| line.iter().copied())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let byte_truncated = selected.len() > request.max_bytes;
|
|
||||||
if byte_truncated {
|
if byte_truncated {
|
||||||
let mut byte_end = request.max_bytes;
|
let mut byte_end = response_limit.min(selected.len());
|
||||||
if let Ok(text) = std::str::from_utf8(&selected) {
|
while byte_end > 0 {
|
||||||
while byte_end > 0 && !text.is_char_boundary(byte_end) {
|
match std::str::from_utf8(&selected[..byte_end]) {
|
||||||
byte_end -= 1;
|
Ok(_) => break,
|
||||||
|
Err(error) if error.error_len().is_none() => byte_end -= 1,
|
||||||
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selected.truncate(byte_end);
|
selected.truncate(byte_end);
|
||||||
@@ -87,7 +179,7 @@ pub fn run_read(
|
|||||||
bytes: selected,
|
bytes: selected,
|
||||||
start_line: request.offset,
|
start_line: request.offset,
|
||||||
total_lines,
|
total_lines,
|
||||||
content_hash,
|
content_hash: content_hasher.finalize().into(),
|
||||||
truncated: end < total_lines || byte_truncated,
|
truncated: end < total_lines || byte_truncated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -173,35 +265,39 @@ pub fn run_list(
|
|||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let logical_base = path.clone();
|
let logical_base = path.clone();
|
||||||
let path = require_access(&path, &logical, access, false, true)?;
|
let path = require_access(&path, &logical, access, false, true)?;
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = access
|
||||||
|
.open_read_file(&logical_base, &path)
|
||||||
|
.and_then(|directory| directory.metadata())
|
||||||
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
if !metadata.is_dir() {
|
if !metadata.is_dir() {
|
||||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||||
}
|
}
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let read_dir = fs::read_dir(&path).map_err(|error| map_io(&logical, error))?;
|
let mut retained_path_bytes = 0_usize;
|
||||||
for entry in read_dir {
|
let mut provider_truncated = false;
|
||||||
|
let read_dir = access
|
||||||
|
.open_read_dir(&logical_base, &path)
|
||||||
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
|
for (entry_index, entry) in read_dir.enumerate() {
|
||||||
|
if entry_index >= crate::MAX_TRAVERSAL_ENTRIES {
|
||||||
|
return Err(FsError::InvalidArgument(format!(
|
||||||
|
"directory traversal exceeds provider limit {}",
|
||||||
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
|
)));
|
||||||
|
}
|
||||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||||
let absolute = entry.path();
|
let logical_absolute = logical_base.join(entry.file_name());
|
||||||
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
|
let resolved = match resolve_access_path(&logical_absolute) {
|
||||||
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
|
|
||||||
})?;
|
|
||||||
let logical_absolute = logical_base.join(relative_to_base);
|
|
||||||
let resolved = match resolve_access_path(&absolute) {
|
|
||||||
Ok(resolved) => resolved,
|
Ok(resolved) => resolved,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
if !access.is_readable_paths(&logical_absolute, &resolved) {
|
if !access.is_readable_paths(&logical_absolute, &resolved) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let link_metadata =
|
let metadata = access
|
||||||
fs::symlink_metadata(&absolute).map_err(|error| map_io(&logical, error))?;
|
.read_metadata(&logical_absolute, &resolved)
|
||||||
let is_symlink = link_metadata.file_type().is_symlink();
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
let metadata = if is_symlink {
|
let kind = if metadata.file_type().is_symlink() {
|
||||||
link_metadata
|
|
||||||
} else {
|
|
||||||
entry.metadata().map_err(|error| map_io(&logical, error))?
|
|
||||||
};
|
|
||||||
let kind = if is_symlink {
|
|
||||||
EntryKind::Symlink
|
EntryKind::Symlink
|
||||||
} else if metadata.is_file() {
|
} else if metadata.is_file() {
|
||||||
EntryKind::File
|
EntryKind::File
|
||||||
@@ -213,8 +309,15 @@ pub fn run_list(
|
|||||||
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
let result_path = FsPath::new(relative.to_string_lossy())?;
|
||||||
|
let retained = result_path.as_str().len().saturating_add(64);
|
||||||
|
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||||
|
provider_truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
retained_path_bytes = retained_path_bytes.saturating_add(retained);
|
||||||
entries.push(ListEntry {
|
entries.push(ListEntry {
|
||||||
path: FsPath::new(relative.to_string_lossy())?,
|
path: result_path,
|
||||||
kind,
|
kind,
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
});
|
});
|
||||||
@@ -228,7 +331,7 @@ pub fn run_list(
|
|||||||
});
|
});
|
||||||
let total_entries = entries.len();
|
let total_entries = entries.len();
|
||||||
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
||||||
let truncated = entries.len() > request.limit;
|
let truncated = provider_truncated || entries.len() > request.limit;
|
||||||
entries.truncate(request.limit);
|
entries.truncate(request.limit);
|
||||||
Ok(ListResult {
|
Ok(ListResult {
|
||||||
entries,
|
entries,
|
||||||
@@ -366,3 +469,71 @@ fn map_io(logical: &FsPath, error: std::io::Error) -> FsError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bounded_read_tests {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct RootAccess(PathBuf);
|
||||||
|
|
||||||
|
impl FsAccessPolicy for RootAccess {
|
||||||
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
|
path.starts_with(&self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_writable(&self, _path: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_read_rejects_an_oversized_source_before_loading_content() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().canonicalize().unwrap();
|
||||||
|
let source = root.join("large.log");
|
||||||
|
let file = fs::File::create(&source).unwrap();
|
||||||
|
file.set_len(4097).unwrap();
|
||||||
|
|
||||||
|
let error = run_read_bounded(
|
||||||
|
&root,
|
||||||
|
ReadRequest {
|
||||||
|
path: FsPath::new("large.log").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: usize::MAX,
|
||||||
|
},
|
||||||
|
&RootAccess(root.clone()),
|
||||||
|
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::InvalidArgument(_)));
|
||||||
|
assert!(error.to_string().contains("exceeds provider limit 4096"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().canonicalize().unwrap();
|
||||||
|
fs::write(root.join("events.log"), "alpha\nbeta\ngamma\n").unwrap();
|
||||||
|
|
||||||
|
let result = run_read_bounded(
|
||||||
|
&root,
|
||||||
|
ReadRequest {
|
||||||
|
path: FsPath::new("events.log").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: usize::MAX,
|
||||||
|
},
|
||||||
|
&RootAccess(root.clone()),
|
||||||
|
BoundedReadLimits::new(1024, 5).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.bytes, b"alpha");
|
||||||
|
assert_eq!(result.total_lines, 3);
|
||||||
|
assert!(result.truncated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,71 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::FsError;
|
use crate::FsError;
|
||||||
|
|
||||||
|
/// Absolute provider-side bounds for one filesystem read.
|
||||||
|
///
|
||||||
|
/// `max_source_bytes` limits how much of the source the provider will inspect
|
||||||
|
/// while computing line and content metadata. `max_response_bytes` limits the
|
||||||
|
/// bytes retained for the response independently of the caller's requested
|
||||||
|
/// `ReadRequest::max_bytes`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct BoundedReadLimits {
|
||||||
|
pub max_source_bytes: u64,
|
||||||
|
pub max_response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundedReadLimits {
|
||||||
|
/// Conservative defaults for a client-hosted, read-only provider.
|
||||||
|
pub const EXTERNAL_DEFAULT: Self = Self {
|
||||||
|
max_source_bytes: 64 * 1024 * 1024,
|
||||||
|
max_response_bytes: 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn new(max_source_bytes: u64, max_response_bytes: usize) -> Result<Self, FsError> {
|
||||||
|
let limits = Self {
|
||||||
|
max_source_bytes,
|
||||||
|
max_response_bytes,
|
||||||
|
};
|
||||||
|
limits.validate()?;
|
||||||
|
Ok(limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(self) -> Result<(), FsError> {
|
||||||
|
if self.max_source_bytes == 0 {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"max_source_bytes must be greater than zero".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.max_response_bytes == 0 {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"max_response_bytes must be greater than zero".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.max_response_bytes as u128 > self.max_source_bytes as u128 {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"max_response_bytes must not exceed max_source_bytes".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BoundedReadLimitsWire {
|
||||||
|
max_source_bytes: u64,
|
||||||
|
max_response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for BoundedReadLimits {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let wire = BoundedReadLimitsWire::deserialize(deserializer)?;
|
||||||
|
Self::new(wire.max_source_bytes, wire.max_response_bytes).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
||||||
/// Workdir root; absolute paths require an explicit matching scope rule.
|
/// Workdir root; absolute paths require an explicit matching scope rule.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
@@ -89,11 +154,13 @@ impl fmt::Display for FsPath {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StatRequest {
|
pub struct StatRequest {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StatResult {
|
pub struct StatResult {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub kind: EntryKind,
|
pub kind: EntryKind,
|
||||||
@@ -112,6 +179,7 @@ pub enum EntryKind {
|
|||||||
pub type ContentHash = [u8; 32];
|
pub type ContentHash = [u8; 32];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ReadRequest {
|
pub struct ReadRequest {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub offset: usize,
|
pub offset: usize,
|
||||||
@@ -120,6 +188,7 @@ pub struct ReadRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ReadResult {
|
pub struct ReadResult {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
@@ -130,6 +199,7 @@ pub struct ReadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WriteRequest {
|
pub struct WriteRequest {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub content: Vec<u8>,
|
pub content: Vec<u8>,
|
||||||
@@ -143,6 +213,7 @@ pub struct WriteResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct EditRequest {
|
pub struct EditRequest {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub old_string: String,
|
pub old_string: String,
|
||||||
@@ -152,6 +223,7 @@ pub struct EditRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct EditResult {
|
pub struct EditResult {
|
||||||
pub replacements: usize,
|
pub replacements: usize,
|
||||||
pub bytes_written: usize,
|
pub bytes_written: usize,
|
||||||
@@ -159,12 +231,14 @@ pub struct EditResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ListRequest {
|
pub struct ListRequest {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ListEntry {
|
pub struct ListEntry {
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
pub kind: EntryKind,
|
pub kind: EntryKind,
|
||||||
@@ -172,6 +246,7 @@ pub struct ListEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ListResult {
|
pub struct ListResult {
|
||||||
pub entries: Vec<ListEntry>,
|
pub entries: Vec<ListEntry>,
|
||||||
pub total_entries: usize,
|
pub total_entries: usize,
|
||||||
@@ -180,6 +255,7 @@ pub struct ListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct GlobRequest {
|
pub struct GlobRequest {
|
||||||
pub pattern: String,
|
pub pattern: String,
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
@@ -187,6 +263,7 @@ pub struct GlobRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct GlobResult {
|
pub struct GlobResult {
|
||||||
pub paths: Vec<FsPath>,
|
pub paths: Vec<FsPath>,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
@@ -207,6 +284,7 @@ impl Default for GrepOutputMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct GrepRequest {
|
pub struct GrepRequest {
|
||||||
pub pattern: String,
|
pub pattern: String,
|
||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
@@ -222,6 +300,7 @@ pub struct GrepRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct GrepResult {
|
pub struct GrepResult {
|
||||||
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
||||||
/// transferring candidate files across a remote provider boundary.
|
/// transferring candidate files across a remote provider boundary.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -14,6 +15,35 @@ use crate::{
|
|||||||
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct SourceBoundedReader<'a, R> {
|
||||||
|
inner: R,
|
||||||
|
remaining: &'a mut u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Read> Read for SourceBoundedReader<'_, R> {
|
||||||
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if buffer.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if *self.remaining == 0 {
|
||||||
|
let mut probe = [0_u8; 1];
|
||||||
|
return match self.inner.read(&mut probe)? {
|
||||||
|
0 => Ok(0),
|
||||||
|
_ => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"grep source exceeded provider byte limit",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let limit = usize::try_from(*self.remaining)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
.min(buffer.len());
|
||||||
|
let read = self.inner.read(&mut buffer[..limit])?;
|
||||||
|
*self.remaining = (*self.remaining).saturating_sub(read as u64);
|
||||||
|
Ok(read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentLine {
|
struct ContentLine {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
line_number: Option<u64>,
|
line_number: Option<u64>,
|
||||||
@@ -27,9 +57,25 @@ struct GrepReport {
|
|||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
counts: Vec<(PathBuf, usize)>,
|
counts: Vec<(PathBuf, usize)>,
|
||||||
lines: Vec<ContentLine>,
|
lines: Vec<ContentLine>,
|
||||||
|
retained_bytes: usize,
|
||||||
truncated: bool,
|
truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_GREP_RESULT_RETAINED_BYTES: usize = 512 * 1024;
|
||||||
|
|
||||||
|
fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool {
|
||||||
|
let Some(next) = report.retained_bytes.checked_add(bytes) else {
|
||||||
|
report.truncated = true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if next > MAX_GREP_RESULT_RETAINED_BYTES {
|
||||||
|
report.truncated = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
report.retained_bytes = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
impl GrepReport {
|
impl GrepReport {
|
||||||
fn into_result(self, root: &Path) -> GrepResult {
|
fn into_result(self, root: &Path) -> GrepResult {
|
||||||
let (match_count, matched_files) = match self.mode {
|
let (match_count, matched_files) = match self.mode {
|
||||||
@@ -281,12 +327,14 @@ pub fn run_grep(
|
|||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
counts: Vec::new(),
|
counts: Vec::new(),
|
||||||
lines: Vec::new(),
|
lines: Vec::new(),
|
||||||
|
retained_bytes: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
let mut matching_files_seen = 0;
|
let mut matching_files_seen = 0;
|
||||||
let mut matches_seen = 0;
|
let mut matches_seen = 0;
|
||||||
|
|
||||||
if base_meta.is_file() {
|
if base_meta.is_file() {
|
||||||
|
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
|
||||||
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
|
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
|
||||||
scan_path(
|
scan_path(
|
||||||
&mut searcher,
|
&mut searcher,
|
||||||
@@ -298,6 +346,8 @@ pub fn run_grep(
|
|||||||
&mut matches_seen,
|
&mut matches_seen,
|
||||||
offset,
|
offset,
|
||||||
head_limit,
|
head_limit,
|
||||||
|
&mut source_bytes_remaining,
|
||||||
|
access,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
return Ok(report.into_result(root));
|
return Ok(report.into_result(root));
|
||||||
@@ -319,7 +369,16 @@ pub fn run_grep(
|
|||||||
walker.overrides(overrides);
|
walker.overrides(overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut visited = 0_usize;
|
||||||
|
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
|
||||||
for entry in walker.build().flatten() {
|
for entry in walker.build().flatten() {
|
||||||
|
visited = visited.saturating_add(1);
|
||||||
|
if visited > crate::MAX_TRAVERSAL_ENTRIES {
|
||||||
|
return Err(FsError::InvalidArgument(format!(
|
||||||
|
"grep traversal exceeds provider limit {}",
|
||||||
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
|
)));
|
||||||
|
}
|
||||||
if !entry
|
if !entry
|
||||||
.file_type()
|
.file_type()
|
||||||
.map(|kind| kind.is_file())
|
.map(|kind| kind.is_file())
|
||||||
@@ -343,6 +402,8 @@ pub fn run_grep(
|
|||||||
&mut matches_seen,
|
&mut matches_seen,
|
||||||
offset,
|
offset,
|
||||||
head_limit,
|
head_limit,
|
||||||
|
&mut source_bytes_remaining,
|
||||||
|
access,
|
||||||
)? {
|
)? {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -362,13 +423,38 @@ fn scan_path(
|
|||||||
matches_seen: &mut usize,
|
matches_seen: &mut usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
head_limit: usize,
|
head_limit: usize,
|
||||||
|
source_bytes_remaining: &mut u64,
|
||||||
|
access: &dyn FsAccessPolicy,
|
||||||
) -> Result<bool, FsError> {
|
) -> Result<bool, FsError> {
|
||||||
|
let reader = access
|
||||||
|
.open_read_file(path, path)
|
||||||
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
|
let metadata = reader
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"grep source must be a regular file".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if metadata.len() > *source_bytes_remaining {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"grep sources exceed provider byte limit".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut reader = SourceBoundedReader {
|
||||||
|
inner: reader,
|
||||||
|
remaining: source_bytes_remaining,
|
||||||
|
};
|
||||||
match mode {
|
match mode {
|
||||||
GrepOutputMode::FilesWithMatches => {
|
GrepOutputMode::FilesWithMatches => {
|
||||||
if !scan_any_match(searcher, matcher, path)? {
|
if !scan_any_match(searcher, matcher, &mut reader, path)? {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if *matching_files_seen >= offset {
|
if *matching_files_seen >= offset {
|
||||||
|
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
report.files.push(path.to_path_buf());
|
report.files.push(path.to_path_buf());
|
||||||
if report.files.len() >= head_limit {
|
if report.files.len() >= head_limit {
|
||||||
report.truncated = true;
|
report.truncated = true;
|
||||||
@@ -378,11 +464,14 @@ fn scan_path(
|
|||||||
*matching_files_seen += 1;
|
*matching_files_seen += 1;
|
||||||
}
|
}
|
||||||
GrepOutputMode::Count => {
|
GrepOutputMode::Count => {
|
||||||
let count = scan_count(searcher, matcher, path)?;
|
let count = scan_count(searcher, matcher, &mut reader, path)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if *matching_files_seen >= offset {
|
if *matching_files_seen >= offset {
|
||||||
|
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
report.counts.push((path.to_path_buf(), count));
|
report.counts.push((path.to_path_buf(), count));
|
||||||
if report.counts.len() >= head_limit {
|
if report.counts.len() >= head_limit {
|
||||||
report.truncated = true;
|
report.truncated = true;
|
||||||
@@ -396,13 +485,18 @@ fn scan_path(
|
|||||||
let mut sink = ContentSink {
|
let mut sink = ContentSink {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
lines: &mut report.lines,
|
lines: &mut report.lines,
|
||||||
|
retained_bytes: &mut report.retained_bytes,
|
||||||
|
truncated: &mut report.truncated,
|
||||||
matches_seen,
|
matches_seen,
|
||||||
offset,
|
offset,
|
||||||
head_limit,
|
head_limit,
|
||||||
};
|
};
|
||||||
searcher
|
searcher
|
||||||
.search_path(matcher, path, &mut sink)
|
.search_reader(matcher, &mut reader, &mut sink)
|
||||||
.map_err(|error| FsError::io(path, error))?;
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
|
if report.truncated {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
|
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
|
||||||
report.truncated = true;
|
report.truncated = true;
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
@@ -415,6 +509,7 @@ fn scan_path(
|
|||||||
fn scan_any_match(
|
fn scan_any_match(
|
||||||
searcher: &mut Searcher,
|
searcher: &mut Searcher,
|
||||||
matcher: &grep_regex::RegexMatcher,
|
matcher: &grep_regex::RegexMatcher,
|
||||||
|
reader: &mut dyn Read,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<bool, FsError> {
|
) -> Result<bool, FsError> {
|
||||||
let mut hit = false;
|
let mut hit = false;
|
||||||
@@ -423,7 +518,7 @@ fn scan_any_match(
|
|||||||
Ok(false) // stop searching this file immediately
|
Ok(false) // stop searching this file immediately
|
||||||
});
|
});
|
||||||
searcher
|
searcher
|
||||||
.search_path(matcher, path, sink)
|
.search_reader(matcher, reader, sink)
|
||||||
.map_err(|e| FsError::io(path, e))?;
|
.map_err(|e| FsError::io(path, e))?;
|
||||||
Ok(hit)
|
Ok(hit)
|
||||||
}
|
}
|
||||||
@@ -431,6 +526,7 @@ fn scan_any_match(
|
|||||||
fn scan_count(
|
fn scan_count(
|
||||||
searcher: &mut Searcher,
|
searcher: &mut Searcher,
|
||||||
matcher: &grep_regex::RegexMatcher,
|
matcher: &grep_regex::RegexMatcher,
|
||||||
|
reader: &mut dyn Read,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<usize, FsError> {
|
) -> Result<usize, FsError> {
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
@@ -439,7 +535,7 @@ fn scan_count(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
});
|
});
|
||||||
searcher
|
searcher
|
||||||
.search_path(matcher, path, sink)
|
.search_reader(matcher, reader, sink)
|
||||||
.map_err(|e| FsError::io(path, e))?;
|
.map_err(|e| FsError::io(path, e))?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
@@ -447,11 +543,34 @@ fn scan_count(
|
|||||||
struct ContentSink<'a> {
|
struct ContentSink<'a> {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
lines: &'a mut Vec<ContentLine>,
|
lines: &'a mut Vec<ContentLine>,
|
||||||
|
retained_bytes: &'a mut usize,
|
||||||
|
truncated: &'a mut bool,
|
||||||
matches_seen: &'a mut usize,
|
matches_seen: &'a mut usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
head_limit: usize,
|
head_limit: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ContentSink<'_> {
|
||||||
|
fn reserve(&mut self, content_bytes: usize) -> bool {
|
||||||
|
let retained = self
|
||||||
|
.path
|
||||||
|
.to_string_lossy()
|
||||||
|
.len()
|
||||||
|
.saturating_add(content_bytes)
|
||||||
|
.saturating_add(64);
|
||||||
|
let Some(next) = (*self.retained_bytes).checked_add(retained) else {
|
||||||
|
*self.truncated = true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if next > MAX_GREP_RESULT_RETAINED_BYTES {
|
||||||
|
*self.truncated = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*self.retained_bytes = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Sink for ContentSink<'_> {
|
impl Sink for ContentSink<'_> {
|
||||||
type Error = std::io::Error;
|
type Error = std::io::Error;
|
||||||
|
|
||||||
@@ -468,6 +587,9 @@ impl Sink for ContentSink<'_> {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.reserve(mat.bytes().len()) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let text = String::from_utf8_lossy(mat.bytes())
|
let text = String::from_utf8_lossy(mat.bytes())
|
||||||
.trim_end_matches('\n')
|
.trim_end_matches('\n')
|
||||||
.trim_end_matches('\r')
|
.trim_end_matches('\r')
|
||||||
@@ -493,6 +615,9 @@ impl Sink for ContentSink<'_> {
|
|||||||
if seen >= self.offset.saturating_add(self.head_limit) {
|
if seen >= self.offset.saturating_add(self.head_limit) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
if !self.reserve(ctx.bytes().len()) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let text = String::from_utf8_lossy(ctx.bytes())
|
let text = String::from_utf8_lossy(ctx.bytes())
|
||||||
.trim_end_matches('\n')
|
.trim_end_matches('\n')
|
||||||
.trim_end_matches('\r')
|
.trim_end_matches('\r')
|
||||||
@@ -506,3 +631,31 @@ impl Sink for ContentSink<'_> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bounded_reader_tests {
|
||||||
|
use std::io::{Cursor, Read as _};
|
||||||
|
|
||||||
|
use super::SourceBoundedReader;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_budget_is_shared_across_candidate_files() {
|
||||||
|
let mut remaining = 3_u64;
|
||||||
|
let mut first = SourceBoundedReader {
|
||||||
|
inner: Cursor::new(b"ab"),
|
||||||
|
remaining: &mut remaining,
|
||||||
|
};
|
||||||
|
let mut output = Vec::new();
|
||||||
|
first.read_to_end(&mut output).unwrap();
|
||||||
|
drop(first);
|
||||||
|
assert_eq!(remaining, 1);
|
||||||
|
|
||||||
|
let mut second = SourceBoundedReader {
|
||||||
|
inner: Cursor::new(b"cd"),
|
||||||
|
remaining: &mut remaining,
|
||||||
|
};
|
||||||
|
let error = second.read_to_end(&mut output).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
assert_eq!(output, b"abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ pub struct LogicalWorkdirAttachment {
|
|||||||
/// Stable Worker-local routing key. This is not a Workdir id or display name.
|
/// Stable Worker-local routing key. This is not a Workdir id or display name.
|
||||||
pub alias: workdir::WorkdirAttachmentAlias,
|
pub alias: workdir::WorkdirAttachmentAlias,
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
|
#[serde(default = "default_workdir_session_capabilities")]
|
||||||
|
pub capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
|
||||||
|
workdir::WorkdirSessionCapabilities::ALL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
|||||||
@@ -1482,6 +1482,7 @@ pub struct Diagnostic {
|
|||||||
pub enum WorkingDirectoryMaterializerKind {
|
pub enum WorkingDirectoryMaterializerKind {
|
||||||
#[default]
|
#[default]
|
||||||
RuntimeGitClone,
|
RuntimeGitClone,
|
||||||
|
ClientHostedExternal,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1612,6 +1613,15 @@ pub struct RuntimeWorkingDirectorySummary {
|
|||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public Workdir source identity. Provider routing details and host paths are intentionally absent.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum WorkingDirectorySource {
|
||||||
|
Repository { repository_key: String },
|
||||||
|
ExternalGrant { grant_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
/// Public, provider-neutral Workdir inventory projection.
|
/// Public, provider-neutral Workdir inventory projection.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
@@ -1622,7 +1632,7 @@ pub struct WorkingDirectorySummary {
|
|||||||
/// Optional human-facing label, never a routing key.
|
/// Optional human-facing label, never a routing key.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub repository_key: String,
|
pub source: WorkingDirectorySource,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub creation_selector: Option<String>,
|
pub creation_selector: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -1662,6 +1672,9 @@ impl From<workdir::workspace::WorkingDirectoryMaterializerKind>
|
|||||||
workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => {
|
workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => {
|
||||||
Self::RuntimeGitClone
|
Self::RuntimeGitClone
|
||||||
}
|
}
|
||||||
|
workdir::workspace::WorkingDirectoryMaterializerKind::ClientHostedExternal => {
|
||||||
|
Self::ClientHostedExternal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1738,7 +1751,14 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
|
|||||||
Self {
|
Self {
|
||||||
working_directory_id: value.working_directory_id,
|
working_directory_id: value.working_directory_id,
|
||||||
display_name: value.display_name,
|
display_name: value.display_name,
|
||||||
repository_key: value.repository_key,
|
source: match value.source {
|
||||||
|
workdir::workspace::WorkingDirectorySource::Repository { repository_key } => {
|
||||||
|
WorkingDirectorySource::Repository { repository_key }
|
||||||
|
}
|
||||||
|
workdir::workspace::WorkingDirectorySource::ExternalGrant { grant_id } => {
|
||||||
|
WorkingDirectorySource::ExternalGrant { grant_id }
|
||||||
|
}
|
||||||
|
},
|
||||||
creation_selector: value.creation_selector,
|
creation_selector: value.creation_selector,
|
||||||
creation_ref: value.creation_ref,
|
creation_ref: value.creation_ref,
|
||||||
creation_tree: value.creation_tree,
|
creation_tree: value.creation_tree,
|
||||||
@@ -1755,6 +1775,31 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExternalWorkdirGrantCreateRequest {
|
||||||
|
pub provider_instance_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub ttl_seconds: u64,
|
||||||
|
pub read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExternalWorkdirGrantResponse {
|
||||||
|
pub grant_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub provider_instance_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub permissions: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub generation: u64,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Browser/Rust-client Workdir materialization request.
|
/// Browser/Rust-client Workdir materialization request.
|
||||||
///
|
///
|
||||||
/// `runtime_id = None` requests Workspace default Runtime resolution and
|
/// `runtime_id = None` requests Workspace default Runtime resolution and
|
||||||
@@ -1791,7 +1836,8 @@ pub struct WorkingDirectoryListResponse {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkingDirectoryDetailResponse {
|
pub struct WorkingDirectoryDetailResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub runtime_id: String,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime_id: Option<String>,
|
||||||
pub item: WorkingDirectorySummary,
|
pub item: WorkingDirectorySummary,
|
||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
@@ -3653,6 +3699,7 @@ pub fn workdir_api_typescript() -> String {
|
|||||||
WorkingDirectoryStatusKind::decl(&config),
|
WorkingDirectoryStatusKind::decl(&config),
|
||||||
WorkingDirectoryCleanupTarget::decl(&config),
|
WorkingDirectoryCleanupTarget::decl(&config),
|
||||||
WorkingDirectoryOccupancy::decl(&config),
|
WorkingDirectoryOccupancy::decl(&config),
|
||||||
|
WorkingDirectorySource::decl(&config),
|
||||||
WorkingDirectorySummary::decl(&config),
|
WorkingDirectorySummary::decl(&config),
|
||||||
WorkingDirectoryCreateRequest::decl(&config),
|
WorkingDirectoryCreateRequest::decl(&config),
|
||||||
WorkingDirectoryListResponse::decl(&config),
|
WorkingDirectoryListResponse::decl(&config),
|
||||||
@@ -3683,6 +3730,7 @@ pub fn worker_launch_api_typescript() -> String {
|
|||||||
RuntimeWorkingDirectoryCleanupTarget::decl(&config),
|
RuntimeWorkingDirectoryCleanupTarget::decl(&config),
|
||||||
RuntimeWorkingDirectorySummary::decl(&config),
|
RuntimeWorkingDirectorySummary::decl(&config),
|
||||||
WorkingDirectoryOccupancy::decl(&config),
|
WorkingDirectoryOccupancy::decl(&config),
|
||||||
|
WorkingDirectorySource::decl(&config),
|
||||||
WorkingDirectorySummary::decl(&config),
|
WorkingDirectorySummary::decl(&config),
|
||||||
WorkerWorkspaceSummary::decl(&config),
|
WorkerWorkspaceSummary::decl(&config),
|
||||||
WorkerImplementationSummary::decl(&config),
|
WorkerImplementationSummary::decl(&config),
|
||||||
@@ -5123,7 +5171,9 @@ mod tests {
|
|||||||
let value = serde_json::to_value(WorkingDirectorySummary {
|
let value = serde_json::to_value(WorkingDirectorySummary {
|
||||||
working_directory_id: "workdir-1".into(),
|
working_directory_id: "workdir-1".into(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
repository_key: "main".into(),
|
source: WorkingDirectorySource::Repository {
|
||||||
|
repository_key: "main".into(),
|
||||||
|
},
|
||||||
creation_selector: None,
|
creation_selector: None,
|
||||||
creation_ref: None,
|
creation_ref: None,
|
||||||
creation_tree: None,
|
creation_tree: None,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::io;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{
|
use client::{
|
||||||
BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability,
|
BackendRuntimeListTarget, BackendWorkerSummary, BackendWorkingDirectorySource,
|
||||||
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
|
WorkerSessionAvailability, list_backend_stopped_workers, list_backend_workers,
|
||||||
|
observe_backend_worker_session,
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -416,10 +417,11 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
|||||||
}
|
}
|
||||||
let attachment = &worker.workdir_attachments[0];
|
let attachment = &worker.workdir_attachments[0];
|
||||||
let directory = &attachment.working_directory;
|
let directory = &attachment.working_directory;
|
||||||
let label = directory
|
let source_label = match &directory.source {
|
||||||
.display_name
|
BackendWorkingDirectorySource::Repository { repository_key } => repository_key.as_str(),
|
||||||
.as_deref()
|
BackendWorkingDirectorySource::ExternalGrant { .. } => "external",
|
||||||
.unwrap_or(&directory.repository_key);
|
};
|
||||||
|
let label = directory.display_name.as_deref().unwrap_or(source_label);
|
||||||
format!(
|
format!(
|
||||||
"wd:{}:{}・{}",
|
"wd:{}:{}・{}",
|
||||||
attachment.alias, label, directory.working_directory_id
|
attachment.alias, label, directory.working_directory_id
|
||||||
@@ -547,7 +549,7 @@ mod tests {
|
|||||||
"working_directory": {
|
"working_directory": {
|
||||||
"working_directory_id": "001a06a9f0202000000",
|
"working_directory_id": "001a06a9f0202000000",
|
||||||
"display_name": "Checkout",
|
"display_name": "Checkout",
|
||||||
"repository_key": "main",
|
"source": {"kind": "repository", "repository_key": "main"},
|
||||||
"materializer_kind": "runtime_git_clone",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"cleanliness": "clean"
|
"cleanliness": "clean"
|
||||||
@@ -591,7 +593,7 @@ mod tests {
|
|||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory": {
|
"working_directory": {
|
||||||
"working_directory_id": "workdir-1",
|
"working_directory_id": "workdir-1",
|
||||||
"repository_key": "main",
|
"source": {"kind": "repository", "repository_key": "main"},
|
||||||
"materializer_kind": "runtime_git_clone",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active"
|
"status": "active"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
//! Versioned websocket DTOs for a client-hosted External Workdir provider.
|
||||||
|
//!
|
||||||
|
//! Authentication belongs to the websocket upgrade and Backend grant
|
||||||
|
//! authority. These frames intentionally contain only opaque identities,
|
||||||
|
//! logical Workdir operations, generation fences, and bounded control data.
|
||||||
|
//! They must never carry a provider host path, credential, or tunnel token.
|
||||||
|
|
||||||
|
use std::path::{Component, Path};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||||
|
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
||||||
|
|
||||||
|
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1;
|
||||||
|
/// Maximum serialized Backend-to-provider command frame size.
|
||||||
|
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||||
|
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
||||||
|
/// represented as a JSON byte array, so this must exceed the semantic payload
|
||||||
|
/// bound while remaining an explicit transport allocation ceiling.
|
||||||
|
pub const MAX_EXTERNAL_PROVIDER_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
const MAX_PROTOCOL_ID_BYTES: usize = 128;
|
||||||
|
const MAX_EXTERNAL_PATH_BYTES: usize = 4 * 1024;
|
||||||
|
const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024;
|
||||||
|
const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||||
|
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||||
|
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||||
|
|
||||||
|
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ExternalWorkdirProtocolVersion(u16);
|
||||||
|
|
||||||
|
impl ExternalWorkdirProtocolVersion {
|
||||||
|
pub const CURRENT: Self = Self(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION);
|
||||||
|
|
||||||
|
pub const fn as_u16(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ExternalWorkdirProtocolVersion {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let version = u16::deserialize(deserializer)?;
|
||||||
|
if version == EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION {
|
||||||
|
Ok(Self::CURRENT)
|
||||||
|
} else {
|
||||||
|
Err(serde::de::Error::custom(format!(
|
||||||
|
"unsupported External Workdir provider protocol version {version}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! protocol_id {
|
||||||
|
($name:ident, $label:literal) => {
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > MAX_PROTOCOL_ID_BYTES
|
||||||
|
|| value.trim() != value
|
||||||
|
|| value.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(concat!($label, " is invalid").to_string());
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for $name {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::new(value).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol_id!(ExternalProviderInstanceId, "provider instance id");
|
||||||
|
protocol_id!(ExternalWorkdirGrantId, "External Workdir grant id");
|
||||||
|
protocol_id!(ExternalWorkdirOperationId, "External Workdir operation id");
|
||||||
|
|
||||||
|
/// First provider frame on a newly authenticated outbound websocket.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExternalWorkdirProviderRegistration {
|
||||||
|
pub provider_instance_id: ExternalProviderInstanceId,
|
||||||
|
pub grant_id: ExternalWorkdirGrantId,
|
||||||
|
pub workdir_id: WorkdirId,
|
||||||
|
/// Monotonic grant connection generation. Reconnect advances it and fences
|
||||||
|
/// every frame from an older connection.
|
||||||
|
pub generation: u64,
|
||||||
|
pub capabilities: WorkdirSessionCapabilities,
|
||||||
|
pub read_limits: BoundedReadLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||||
|
let path_value = path.as_str();
|
||||||
|
let path = Path::new(path_value);
|
||||||
|
path_value.len() <= MAX_EXTERNAL_PATH_BYTES
|
||||||
|
&& !path.is_absolute()
|
||||||
|
&& !path.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||||
|
let path_is_valid = match operation {
|
||||||
|
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||||
|
is_root_relative(&request.path)
|
||||||
|
&& request.rules.len() <= MAX_EXTERNAL_SCOPE_RULES
|
||||||
|
&& request
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.all(|rule| is_root_relative(&rule.target))
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
|
||||||
|
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
|
||||||
|
WorkdirSessionOperation::Read(request) => {
|
||||||
|
is_root_relative(&request.path)
|
||||||
|
&& request.limit <= 1_000_000
|
||||||
|
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::List(request) => {
|
||||||
|
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::Glob(request) => {
|
||||||
|
is_root_relative(&request.path)
|
||||||
|
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
|
||||||
|
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::Grep(request) => {
|
||||||
|
is_root_relative(&request.path)
|
||||||
|
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
|
||||||
|
&& request
|
||||||
|
.glob
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|glob| glob.len() <= MAX_EXTERNAL_PATTERN_BYTES)
|
||||||
|
&& request
|
||||||
|
.file_type
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|file_type| file_type.len() <= 128)
|
||||||
|
&& request.before_context <= MAX_EXTERNAL_GREP_CONTEXT
|
||||||
|
&& request.after_context <= MAX_EXTERNAL_GREP_CONTEXT
|
||||||
|
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
&& request.offset <= 1_000_000
|
||||||
|
}
|
||||||
|
WorkdirSessionOperation::Write(_)
|
||||||
|
| WorkdirSessionOperation::Edit(_)
|
||||||
|
| WorkdirSessionOperation::CommandStart(_)
|
||||||
|
| WorkdirSessionOperation::CommandStatus(_)
|
||||||
|
| WorkdirSessionOperation::CommandOutput(_)
|
||||||
|
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||||
|
return Err("operation is not available to a read-only External Workdir".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if path_is_valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("External Workdir operation paths must be root-relative".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated read-only subset of the shared Workdir operation contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ExternalWorkdirOperation(WorkdirSessionOperation);
|
||||||
|
|
||||||
|
impl ExternalWorkdirOperation {
|
||||||
|
pub fn into_inner(self) -> WorkdirSessionOperation {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_inner(&self) -> &WorkdirSessionOperation {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||||
|
validate_read_only_operation(&operation)?;
|
||||||
|
Ok(Self(operation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ExternalWorkdirOperation {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let operation = WorkdirSessionOperation::deserialize(deserializer)?;
|
||||||
|
Self::try_from(operation).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> bool {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.try_fold(0_usize, |retained, path| {
|
||||||
|
retained.checked_add(path.as_str().len().saturating_add(64))
|
||||||
|
})
|
||||||
|
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||||
|
let result_is_valid = match result {
|
||||||
|
WorkdirSessionOperationResult::AuthorizeScope
|
||||||
|
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||||
|
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
|
||||||
|
WorkdirSessionOperationResult::Read(result) => {
|
||||||
|
is_root_relative(&result.path)
|
||||||
|
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||||
|
}
|
||||||
|
WorkdirSessionOperationResult::List(result) => {
|
||||||
|
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
&& result
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.all(|entry| is_root_relative(&entry.path))
|
||||||
|
&& result_paths_fit(result.entries.iter().map(|entry| &entry.path))
|
||||||
|
}
|
||||||
|
WorkdirSessionOperationResult::Glob(result) => {
|
||||||
|
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
&& result.paths.iter().all(is_root_relative)
|
||||||
|
&& result_paths_fit(&result.paths)
|
||||||
|
}
|
||||||
|
WorkdirSessionOperationResult::Grep(result) => {
|
||||||
|
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||||
|
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
}
|
||||||
|
WorkdirSessionOperationResult::Write(_)
|
||||||
|
| WorkdirSessionOperationResult::Edit(_)
|
||||||
|
| WorkdirSessionOperationResult::CommandStart(_)
|
||||||
|
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||||
|
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||||
|
| WorkdirSessionOperationResult::CommandCancel => {
|
||||||
|
return Err(
|
||||||
|
"operation result is not available to a read-only External Workdir".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result_is_valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("External Workdir result exceeds path or response bounds".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated read-only subset of the shared Workdir operation result contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult);
|
||||||
|
|
||||||
|
impl ExternalWorkdirOperationResult {
|
||||||
|
pub fn into_inner(self) -> WorkdirSessionOperationResult {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_inner(&self) -> &WorkdirSessionOperationResult {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||||
|
validate_read_only_result(&result)?;
|
||||||
|
Ok(Self(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let result = WorkdirSessionOperationResult::deserialize(deserializer)?;
|
||||||
|
Self::try_from(result).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum ExternalWorkdirOperationOutcome {
|
||||||
|
Completed {
|
||||||
|
result: ExternalWorkdirOperationResult,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
error: WorkdirTransportError,
|
||||||
|
},
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ExternalWorkdirRevokeReason {
|
||||||
|
UserRequested,
|
||||||
|
Expired,
|
||||||
|
AuthorityWithdrawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames sent by the authenticated Backend to the outbound provider.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum ExternalWorkdirServerMessage {
|
||||||
|
Registered {
|
||||||
|
generation: u64,
|
||||||
|
heartbeat_interval_ms: u64,
|
||||||
|
max_in_flight_operations: usize,
|
||||||
|
},
|
||||||
|
Operation {
|
||||||
|
generation: u64,
|
||||||
|
operation_id: ExternalWorkdirOperationId,
|
||||||
|
operation: ExternalWorkdirOperation,
|
||||||
|
},
|
||||||
|
Cancel {
|
||||||
|
generation: u64,
|
||||||
|
operation_id: ExternalWorkdirOperationId,
|
||||||
|
},
|
||||||
|
Revoke {
|
||||||
|
generation: u64,
|
||||||
|
reason: ExternalWorkdirRevokeReason,
|
||||||
|
},
|
||||||
|
Heartbeat {
|
||||||
|
generation: u64,
|
||||||
|
sequence: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames sent by the client-hosted provider over its outbound connection.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum ExternalWorkdirProviderMessage {
|
||||||
|
Register {
|
||||||
|
registration: ExternalWorkdirProviderRegistration,
|
||||||
|
},
|
||||||
|
OperationResult {
|
||||||
|
generation: u64,
|
||||||
|
operation_id: ExternalWorkdirOperationId,
|
||||||
|
outcome: ExternalWorkdirOperationOutcome,
|
||||||
|
},
|
||||||
|
Heartbeat {
|
||||||
|
generation: u64,
|
||||||
|
sequence: u64,
|
||||||
|
},
|
||||||
|
RevokeAcknowledged {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versioned server-to-provider websocket envelope.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExternalWorkdirServerFrame {
|
||||||
|
pub version: ExternalWorkdirProtocolVersion,
|
||||||
|
pub message: ExternalWorkdirServerMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalWorkdirServerFrame {
|
||||||
|
pub fn current(message: ExternalWorkdirServerMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
version: ExternalWorkdirProtocolVersion::CURRENT,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versioned provider-to-server websocket envelope.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExternalWorkdirProviderFrame {
|
||||||
|
pub version: ExternalWorkdirProtocolVersion,
|
||||||
|
pub message: ExternalWorkdirProviderMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalWorkdirProviderFrame {
|
||||||
|
pub fn current(message: ExternalWorkdirProviderMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
version: ExternalWorkdirProtocolVersion::CURRENT,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath};
|
||||||
|
|
||||||
|
fn assert_no_provider_authority(value: &Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
for (key, value) in object {
|
||||||
|
assert!(
|
||||||
|
![
|
||||||
|
"host_path",
|
||||||
|
"root",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
"token",
|
||||||
|
"access_token",
|
||||||
|
"bearer_token",
|
||||||
|
"endpoint",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
.contains(&key.as_str()),
|
||||||
|
"provider authority field leaked into protocol: {key}"
|
||||||
|
);
|
||||||
|
assert_no_provider_authority(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => values.iter().for_each(assert_no_provider_authority),
|
||||||
|
Value::String(value) => {
|
||||||
|
assert!(!value.contains("/home/operator/private"));
|
||||||
|
assert!(!value.contains("provider-secret"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_is_versioned_correlated_and_contains_no_provider_authority() {
|
||||||
|
let workdir = Workdir::new("working-directory-1");
|
||||||
|
let registration =
|
||||||
|
ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register {
|
||||||
|
registration: ExternalWorkdirProviderRegistration {
|
||||||
|
provider_instance_id: ExternalProviderInstanceId::new("provider-1").unwrap(),
|
||||||
|
grant_id: ExternalWorkdirGrantId::new("grant-1").unwrap(),
|
||||||
|
workdir_id: workdir.id().clone(),
|
||||||
|
generation: 7,
|
||||||
|
capabilities: WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
read_limits: BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let operation =
|
||||||
|
ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Operation {
|
||||||
|
generation: 7,
|
||||||
|
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||||
|
operation: WorkdirSessionOperation::Read(ReadRequest {
|
||||||
|
path: WorkdirPath::new("sessions/events.jsonl").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
max_bytes: 1024,
|
||||||
|
})
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for value in [
|
||||||
|
serde_json::to_value(registration).unwrap(),
|
||||||
|
serde_json::to_value(operation).unwrap(),
|
||||||
|
serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||||
|
ExternalWorkdirServerMessage::Cancel {
|
||||||
|
generation: 7,
|
||||||
|
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||||
|
ExternalWorkdirServerMessage::Revoke {
|
||||||
|
generation: 7,
|
||||||
|
reason: ExternalWorkdirRevokeReason::Expired,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::to_value(ExternalWorkdirProviderFrame::current(
|
||||||
|
ExternalWorkdirProviderMessage::Heartbeat {
|
||||||
|
generation: 7,
|
||||||
|
sequence: 3,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
value.get("version").and_then(Value::as_u64),
|
||||||
|
Some(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION.into())
|
||||||
|
);
|
||||||
|
assert_no_provider_authority(&value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||||
|
let future = r#"{
|
||||||
|
"version": 2,
|
||||||
|
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||||
|
}"#;
|
||||||
|
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
|
||||||
|
|
||||||
|
let invalid_limits = r#"{
|
||||||
|
"max_source_bytes": 1024,
|
||||||
|
"max_response_bytes": 2048
|
||||||
|
}"#;
|
||||||
|
assert!(serde_json::from_str::<BoundedReadLimits>(invalid_limits).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_protocol_rejects_host_paths_and_command_payloads() {
|
||||||
|
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||||
|
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
max_bytes: 1024,
|
||||||
|
});
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
|
||||||
|
|
||||||
|
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||||
|
command: "cat /home/operator/private/session.log".to_string(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
|
spill_dir: Some("/home/operator/private/output".into()),
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||||
|
let unknown = r#"{
|
||||||
|
"version": 1,
|
||||||
|
"message": {
|
||||||
|
"kind": "heartbeat",
|
||||||
|
"generation": 1,
|
||||||
|
"sequence": 1,
|
||||||
|
"credential": "must-not-be-ignored"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(unknown).is_err());
|
||||||
|
|
||||||
|
let mut nested = serde_json::to_value(ExternalWorkdirServerFrame::current(
|
||||||
|
ExternalWorkdirServerMessage::Operation {
|
||||||
|
generation: 1,
|
||||||
|
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
|
||||||
|
operation: WorkdirSessionOperation::Read(ReadRequest {
|
||||||
|
path: WorkdirPath::new("events.jsonl").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
max_bytes: 1024,
|
||||||
|
})
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
nested["message"]["operation"]["request"]["credential"] =
|
||||||
|
Value::String("must-not-be-ignored".to_string());
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
||||||
|
|
||||||
|
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||||
|
});
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
|
||||||
|
|
||||||
|
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
|
||||||
|
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
|
||||||
|
match_count: 1,
|
||||||
|
matched_files: 1,
|
||||||
|
truncated: true,
|
||||||
|
});
|
||||||
|
assert!(ExternalWorkdirOperationResult::try_from(oversized_result).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_ids_are_bounded_and_control_free() {
|
||||||
|
assert!(ExternalWorkdirOperationId::new("").is_err());
|
||||||
|
assert!(ExternalWorkdirOperationId::new(" operation-1").is_err());
|
||||||
|
assert!(ExternalWorkdirOperationId::new("operation\n1").is_err());
|
||||||
|
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
|
||||||
|
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
use crate::http::{
|
||||||
|
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
BoundedReadLimits, CommandRequest, EditRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||||
|
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||||
|
WorkdirSessionCapability, WriteRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||||
|
LocalWorkdirSession::external_read_only(Workdir::new("external-workdir-1"), root.path(), limits)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_local_provider_is_strictly_read_only() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("item.txt"), "original").unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.capabilities(),
|
||||||
|
WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
|
|
||||||
|
let write = WorkdirSession::write(
|
||||||
|
&session,
|
||||||
|
WriteRequest {
|
||||||
|
path: WorkdirPath::new("item.txt").unwrap(),
|
||||||
|
content: b"changed".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
write,
|
||||||
|
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
|
||||||
|
));
|
||||||
|
|
||||||
|
let edit = WorkdirSession::edit(
|
||||||
|
&session,
|
||||||
|
EditRequest {
|
||||||
|
path: WorkdirPath::new("item.txt").unwrap(),
|
||||||
|
old_string: "original".to_string(),
|
||||||
|
new_string: "changed".to_string(),
|
||||||
|
replace_all: false,
|
||||||
|
expected_hash: [0; 32],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
edit,
|
||||||
|
WorkdirError::Unsupported(WorkdirSessionCapability::Edit)
|
||||||
|
));
|
||||||
|
|
||||||
|
let command = WorkdirSession::start_command(
|
||||||
|
&session,
|
||||||
|
CommandRequest {
|
||||||
|
command: "echo changed > item.txt".to_string(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
|
spill_dir: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
command,
|
||||||
|
WorkdirError::Unsupported(WorkdirSessionCapability::Command)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(root.path().join("item.txt")).unwrap(),
|
||||||
|
"original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("item.txt"), "visible").unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||||
|
|
||||||
|
let result = dispatch_workdir_session_operation(
|
||||||
|
&session,
|
||||||
|
WorkdirSessionOperation::Read(ReadRequest {
|
||||||
|
path: WorkdirPath::new("item.txt").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
WorkdirSessionOperationResult::Read(ref result) if result.bytes == b"visible"
|
||||||
|
));
|
||||||
|
|
||||||
|
let error = dispatch_workdir_session_operation(
|
||||||
|
&session,
|
||||||
|
WorkdirSessionOperation::Write(WriteRequest {
|
||||||
|
path: WorkdirPath::new("item.txt").unwrap(),
|
||||||
|
content: b"blocked".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_local_provider_rejects_absolute_and_parent_paths() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||||
|
|
||||||
|
assert!(WorkdirPath::new("../outside.txt").is_err());
|
||||||
|
let absolute = WorkdirPath::new_scoped(outside.path().to_string_lossy()).unwrap();
|
||||||
|
let error = WorkdirSession::read(
|
||||||
|
&session,
|
||||||
|
ReadRequest {
|
||||||
|
path: absolute,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::InvalidPath(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_local_provider_enforces_source_and_response_bounds() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("large.log"), vec![b'x'; 2048]).unwrap();
|
||||||
|
std::fs::write(root.path().join("small.log"), b"0123456789\n").unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(1024, 4).unwrap());
|
||||||
|
|
||||||
|
let error = WorkdirSession::read(
|
||||||
|
&session,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new("large.log").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: usize::MAX,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::InvalidArgument(_)));
|
||||||
|
|
||||||
|
let bounded = WorkdirSession::read(
|
||||||
|
&session,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new("small.log").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: usize::MAX,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bounded.bytes, b"0123");
|
||||||
|
assert!(bounded.truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
|
||||||
|
symlink(
|
||||||
|
outside.path().join("secret.txt"),
|
||||||
|
root.path().join("outside.txt"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(root.path().join("inside.txt"), "inside").unwrap();
|
||||||
|
symlink(
|
||||||
|
root.path().join("inside.txt"),
|
||||||
|
root.path().join("inside-link.txt"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||||
|
|
||||||
|
for path in ["outside.txt", "inside-link.txt"] {
|
||||||
|
let error = WorkdirSession::read(
|
||||||
|
&session,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new(path).unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = tempfile::tempdir().unwrap();
|
||||||
|
let root_link = parent.path().join("shared-root");
|
||||||
|
symlink(root.path(), &root_link).unwrap();
|
||||||
|
let error = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("external-workdir-2"),
|
||||||
|
&root_link,
|
||||||
|
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_local_provider_pins_the_approved_root_across_directory_replacement() {
|
||||||
|
let parent = tempfile::tempdir().unwrap();
|
||||||
|
let approved = parent.path().join("shared");
|
||||||
|
std::fs::create_dir(&approved).unwrap();
|
||||||
|
std::fs::write(approved.join("item.txt"), "approved").unwrap();
|
||||||
|
let session = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("external-workdir-pinned-root"),
|
||||||
|
&approved,
|
||||||
|
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
|
||||||
|
std::fs::create_dir(&approved).unwrap();
|
||||||
|
std::fs::write(approved.join("item.txt"), "replacement").unwrap();
|
||||||
|
|
||||||
|
let read = WorkdirSession::read(
|
||||||
|
&session,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new("item.txt").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(read.bytes, b"approved");
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ use crate::{
|
|||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
WriteRequest, WriteResult,
|
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||||
@@ -54,7 +54,12 @@ pub struct OpenWorkdirSessionResponse {
|
|||||||
|
|
||||||
/// One provider-side Workdir operation.
|
/// One provider-side Workdir operation.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
#[serde(
|
||||||
|
tag = "operation",
|
||||||
|
content = "request",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
pub enum WorkdirSessionOperation {
|
pub enum WorkdirSessionOperation {
|
||||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||||
@@ -80,7 +85,12 @@ pub struct WorkdirSessionOperationRequest {
|
|||||||
|
|
||||||
/// Typed result paired with [`WorkdirSessionOperation`].
|
/// Typed result paired with [`WorkdirSessionOperation`].
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
#[serde(
|
||||||
|
tag = "operation",
|
||||||
|
content = "result",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
pub enum WorkdirSessionOperationResult {
|
pub enum WorkdirSessionOperationResult {
|
||||||
AuthorizeScope,
|
AuthorizeScope,
|
||||||
ScopeRulesOverlap { overlaps: bool },
|
ScopeRulesOverlap { overlaps: bool },
|
||||||
@@ -97,6 +107,72 @@ pub enum WorkdirSessionOperationResult {
|
|||||||
CommandCancel,
|
CommandCancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatch one typed operation against any provider-neutral Workdir session.
|
||||||
|
///
|
||||||
|
/// Runtime HTTP handlers, outbound External providers, and Backend brokers can
|
||||||
|
/// share this operation/result pairing while keeping transport authorization,
|
||||||
|
/// generation fencing, cancellation timeouts, and session lookup at their own
|
||||||
|
/// boundaries.
|
||||||
|
pub async fn dispatch_workdir_session_operation(
|
||||||
|
session: &dyn WorkdirSession,
|
||||||
|
operation: WorkdirSessionOperation,
|
||||||
|
) -> Result<WorkdirSessionOperationResult, WorkdirError> {
|
||||||
|
match operation {
|
||||||
|
WorkdirSessionOperation::AuthorizeScope(request) => session
|
||||||
|
.authorize_scope_path(request)
|
||||||
|
.await
|
||||||
|
.map(|()| WorkdirSessionOperationResult::AuthorizeScope),
|
||||||
|
WorkdirSessionOperation::ScopeRulesOverlap(request) => session
|
||||||
|
.scope_rules_overlap(request)
|
||||||
|
.await
|
||||||
|
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
|
||||||
|
WorkdirSessionOperation::Stat(request) => session
|
||||||
|
.stat(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Stat),
|
||||||
|
WorkdirSessionOperation::Read(request) => session
|
||||||
|
.read(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Read),
|
||||||
|
WorkdirSessionOperation::Write(request) => session
|
||||||
|
.write(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Write),
|
||||||
|
WorkdirSessionOperation::Edit(request) => session
|
||||||
|
.edit(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Edit),
|
||||||
|
WorkdirSessionOperation::List(request) => session
|
||||||
|
.list(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::List),
|
||||||
|
WorkdirSessionOperation::Glob(request) => session
|
||||||
|
.glob(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Glob),
|
||||||
|
WorkdirSessionOperation::Grep(request) => session
|
||||||
|
.grep(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::Grep),
|
||||||
|
WorkdirSessionOperation::CommandStart(request) => session
|
||||||
|
.start_command(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::CommandStart),
|
||||||
|
WorkdirSessionOperation::CommandStatus(handle) => session
|
||||||
|
.command_status(handle)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::CommandStatus),
|
||||||
|
WorkdirSessionOperation::CommandOutput(request) => session
|
||||||
|
.command_output(request)
|
||||||
|
.await
|
||||||
|
.map(WorkdirSessionOperationResult::CommandOutput),
|
||||||
|
WorkdirSessionOperation::CommandCancel(handle) => session
|
||||||
|
.cancel_command(handle)
|
||||||
|
.await
|
||||||
|
.map(|()| WorkdirSessionOperationResult::CommandCancel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable, host-path-free error code crossing the Runtime boundary.
|
/// Stable, host-path-free error code crossing the Runtime boundary.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||||
//! materialization or cleanup.
|
//! materialization or cleanup.
|
||||||
|
|
||||||
|
pub mod external;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
mod local;
|
mod local;
|
||||||
mod operation;
|
mod operation;
|
||||||
@@ -12,6 +13,9 @@ mod router;
|
|||||||
mod scope;
|
mod scope;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod external_local_tests;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -20,10 +24,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub use fs_operation::{
|
pub use fs_operation::{
|
||||||
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
pub use http::dispatch_workdir_session_operation;
|
||||||
pub use local::{
|
pub use local::{
|
||||||
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||||
};
|
};
|
||||||
|
|||||||
+260
-23
@@ -18,17 +18,17 @@ use std::sync::{Arc, Mutex as StdMutex};
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, broadcast, watch};
|
use tokio::sync::{Mutex, broadcast, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest,
|
||||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||||
WriteResult,
|
WriteResult,
|
||||||
@@ -203,26 +203,112 @@ impl CommandTelemetry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ScopeAccess(Arc<Scope>);
|
struct ScopeAccess {
|
||||||
|
scope: Arc<Scope>,
|
||||||
|
root: PathBuf,
|
||||||
|
pinned_root: Option<Arc<std::fs::File>>,
|
||||||
|
reject_symlinks: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeAccess {
|
||||||
|
fn new(
|
||||||
|
scope: Arc<Scope>,
|
||||||
|
root: &Path,
|
||||||
|
pinned_root: Option<Arc<std::fs::File>>,
|
||||||
|
reject_symlinks: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
scope,
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
pinned_root,
|
||||||
|
reject_symlinks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_confined(&self, path: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
let root = self.pinned_root.as_ref().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"External Workdir root handle is unavailable",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"path is outside provider root",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
fs_operation::open_beneath_no_symlinks_at(root, relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
|
!self.reject_symlinks
|
||||||
|
|| (logical.starts_with(&self.root)
|
||||||
|
&& resolved.starts_with(&self.root)
|
||||||
|
&& fs_operation::first_symlink(logical).is_none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fs_operation::FsAccessPolicy for ScopeAccess {
|
impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||||
fn is_readable(&self, path: &Path) -> bool {
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
self.0.is_readable(path)
|
self.scope.is_readable(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_writable(&self, path: &Path) -> bool {
|
fn is_writable(&self, path: &Path) -> bool {
|
||||||
self.0.is_writable(path)
|
self.scope.is_writable(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
if self.reject_symlinks {
|
||||||
|
self.open_confined(resolved)
|
||||||
|
} else {
|
||||||
|
std::fs::File::open(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
||||||
|
if self.reject_symlinks {
|
||||||
|
self.open_confined(resolved)?.metadata()
|
||||||
|
} else {
|
||||||
|
std::fs::symlink_metadata(logical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||||
|
if !self.reject_symlinks {
|
||||||
|
return std::fs::read_dir(resolved);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let directory = self.open_confined(resolved)?;
|
||||||
|
if !directory.metadata()?.is_dir() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotADirectory,
|
||||||
|
"path is not a directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"root-confined directory enumeration is unavailable on this platform",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
matches!(
|
self.path_is_confined(logical, resolved)
|
||||||
self.0.permission_at_paths(logical, resolved),
|
&& matches!(
|
||||||
Some(Permission::Read | Permission::Write)
|
self.scope.permission_at_paths(logical, resolved),
|
||||||
)
|
Some(Permission::Read | Permission::Write)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
self.path_is_confined(logical, resolved)
|
||||||
|
&& self.scope.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +351,12 @@ fn rule_targets(
|
|||||||
struct LocalWorkdirSessionInner {
|
struct LocalWorkdirSessionInner {
|
||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
pinned_root: Option<Arc<std::fs::File>>,
|
||||||
scope: SharedScope,
|
scope: SharedScope,
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
read_limits: Option<BoundedReadLimits>,
|
||||||
|
reject_symlinks: bool,
|
||||||
closed: AtomicBool,
|
closed: AtomicBool,
|
||||||
close_lock: Mutex<()>,
|
close_lock: Mutex<()>,
|
||||||
next_command_id: AtomicU64,
|
next_command_id: AtomicU64,
|
||||||
@@ -388,14 +477,113 @@ impl LocalWorkdirSession {
|
|||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
command_environment: BTreeMap<String, String>,
|
command_environment: BTreeMap<String, String>,
|
||||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||||
|
) -> Self {
|
||||||
|
Self::materialized_bound_with_policy(
|
||||||
|
workdir,
|
||||||
|
root,
|
||||||
|
cwd,
|
||||||
|
scope,
|
||||||
|
capabilities,
|
||||||
|
command_environment,
|
||||||
|
resources,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a strictly read-only provider session for an
|
||||||
|
/// authority-assigned Workdir and an operator-selected local directory.
|
||||||
|
///
|
||||||
|
/// The selected directory is canonicalized and fixed for the lifetime of
|
||||||
|
/// the session. A symlink selected as the root is rejected, and all
|
||||||
|
/// operation paths are confined below the canonical root without following
|
||||||
|
/// symbolic links. Read source and response sizes are provider-bounded.
|
||||||
|
pub fn external_read_only(
|
||||||
|
workdir: Workdir,
|
||||||
|
directory: impl AsRef<Path>,
|
||||||
|
read_limits: BoundedReadLimits,
|
||||||
|
) -> Result<Self, WorkdirError> {
|
||||||
|
read_limits.validate().map_err(WorkdirError::from)?;
|
||||||
|
let directory = directory.as_ref();
|
||||||
|
if !directory.is_absolute() {
|
||||||
|
return Err(WorkdirError::InvalidArgument(
|
||||||
|
"External Workdir directory must be absolute".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let selected_metadata = std::fs::symlink_metadata(directory)
|
||||||
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||||
|
if selected_metadata.file_type().is_symlink() {
|
||||||
|
return Err(WorkdirError::Denied(
|
||||||
|
"External Workdir root must not be a symbolic link".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let root = directory
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||||
|
if !root
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::InvalidArgument(
|
||||||
|
"External Workdir root must be a directory".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pinned_root = Arc::new(
|
||||||
|
fs_operation::open_root_no_symlinks(&root)
|
||||||
|
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?,
|
||||||
|
);
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: root.clone(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
|
}],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.map_err(|_| {
|
||||||
|
WorkdirError::InvalidArgument(
|
||||||
|
"External Workdir read scope could not be established".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self::materialized_bound_with_policy(
|
||||||
|
workdir,
|
||||||
|
root.clone(),
|
||||||
|
root,
|
||||||
|
SharedScope::new(scope),
|
||||||
|
WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
BTreeMap::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Some(pinned_root),
|
||||||
|
Some(read_limits),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialized_bound_with_policy(
|
||||||
|
workdir: Workdir,
|
||||||
|
root: PathBuf,
|
||||||
|
cwd: PathBuf,
|
||||||
|
scope: SharedScope,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
command_environment: BTreeMap<String, String>,
|
||||||
|
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||||
|
pinned_root: Option<Arc<std::fs::File>>,
|
||||||
|
read_limits: Option<BoundedReadLimits>,
|
||||||
|
reject_symlinks: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(LocalWorkdirSessionInner {
|
inner: Arc::new(LocalWorkdirSessionInner {
|
||||||
workdir,
|
workdir,
|
||||||
root,
|
root,
|
||||||
|
pinned_root,
|
||||||
scope,
|
scope,
|
||||||
cwd,
|
cwd,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
read_limits,
|
||||||
|
reject_symlinks,
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
close_lock: Mutex::new(()),
|
close_lock: Mutex::new(()),
|
||||||
next_command_id: AtomicU64::new(1),
|
next_command_id: AtomicU64::new(1),
|
||||||
@@ -592,6 +780,37 @@ impl LocalWorkdirSession {
|
|||||||
self.inner.root.join(path.as_str())
|
self.inner.root.join(path.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_operation_path(&self, path: &WorkdirPath) -> Result<(), WorkdirError> {
|
||||||
|
if !self.inner.reject_symlinks {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = Path::new(path.as_str());
|
||||||
|
if path.is_absolute()
|
||||||
|
|| path.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
std::path::Component::ParentDir
|
||||||
|
| std::path::Component::RootDir
|
||||||
|
| std::path::Component::Prefix(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::InvalidPath(
|
||||||
|
"External Workdir paths must be root-relative".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_access(&self) -> ScopeAccess {
|
||||||
|
ScopeAccess::new(
|
||||||
|
self.inner.scope.snapshot(),
|
||||||
|
&self.inner.root,
|
||||||
|
self.inner.pinned_root.clone(),
|
||||||
|
self.inner.reject_symlinks,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -609,7 +828,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
) -> Result<(), WorkdirError> {
|
) -> Result<(), WorkdirError> {
|
||||||
self.ensure_open()?;
|
self.ensure_open()?;
|
||||||
|
self.validate_operation_path(&request.path)?;
|
||||||
let logical = self.inner.root.join(request.path.as_str());
|
let logical = self.inner.root.join(request.path.as_str());
|
||||||
|
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
|
||||||
|
return Err(WorkdirError::Denied(
|
||||||
|
"External Workdir scope cannot traverse symbolic links".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let resolved = fs_operation::resolve_access_path(&logical)
|
let resolved = fs_operation::resolve_access_path(&logical)
|
||||||
.map_err(|error| WorkdirError::io(&logical, error))?;
|
.map_err(|error| WorkdirError::io(&logical, error))?;
|
||||||
let parent_permission = self
|
let parent_permission = self
|
||||||
@@ -687,7 +912,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_stat(&self.inner.root, request, &access)
|
fs_operation::run_stat(&self.inner.root, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
@@ -696,16 +922,23 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
fs_operation::run_read(&self.inner.root, request, &access)
|
let access = self.scope_access();
|
||||||
.map_err(WorkdirError::from)
|
match self.inner.read_limits {
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
Some(limits) => {
|
||||||
|
fs_operation::run_read_bounded(&self.inner.root, request, &access, limits)
|
||||||
|
}
|
||||||
|
None => fs_operation::run_read(&self.inner.root, request, &access),
|
||||||
|
}
|
||||||
|
.map_err(WorkdirError::from)
|
||||||
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_write(&self.inner.root, request, &access)
|
fs_operation::run_write(&self.inner.root, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
@@ -714,7 +947,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Edit)?;
|
self.ensure_capability(WorkdirSessionCapability::Edit)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_edit(&self.inner.root, request, &access)
|
fs_operation::run_edit(&self.inner.root, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
@@ -723,7 +957,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_list(&self.inner.root, request, &access)
|
fs_operation::run_list(&self.inner.root, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
@@ -733,7 +968,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.ensure_capability(WorkdirSessionCapability::Glob)?;
|
self.ensure_capability(WorkdirSessionCapability::Glob)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let base = self.resolve(&request.path);
|
let base = self.resolve(&request.path);
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_glob(&self.inner.root, &base, request, &access)
|
fs_operation::run_glob(&self.inner.root, &base, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
@@ -743,7 +979,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.ensure_capability(WorkdirSessionCapability::Grep)?;
|
self.ensure_capability(WorkdirSessionCapability::Grep)?;
|
||||||
let base = self.resolve(&request.path);
|
let base = self.resolve(&request.path);
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
let access = ScopeAccess(self.inner.scope.snapshot());
|
self.validate_operation_path(&request.path)?;
|
||||||
|
let access = self.scope_access();
|
||||||
fs_operation::run_grep(&self.inner.root, base, request, &access)
|
fs_operation::run_grep(&self.inner.root, base, request, &access)
|
||||||
.map_err(WorkdirError::from)
|
.map_err(WorkdirError::from)
|
||||||
.map_err(|error| sanitize_error(error, &logical))
|
.map_err(|error| sanitize_error(error, &logical))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub enum WorkingDirectoryMaterializerKind {
|
pub enum WorkingDirectoryMaterializerKind {
|
||||||
#[default]
|
#[default]
|
||||||
RuntimeGitClone,
|
RuntimeGitClone,
|
||||||
|
ClientHostedExternal,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -98,6 +99,13 @@ pub struct RuntimeWorkingDirectorySummary {
|
|||||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum WorkingDirectorySource {
|
||||||
|
Repository { repository_key: String },
|
||||||
|
ExternalGrant { grant_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkingDirectorySummary {
|
pub struct WorkingDirectorySummary {
|
||||||
@@ -105,7 +113,7 @@ pub struct WorkingDirectorySummary {
|
|||||||
/// Optional human-facing label. Never used for attachment routing.
|
/// Optional human-facing label. Never used for attachment routing.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub repository_key: String,
|
pub source: WorkingDirectorySource,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub creation_selector: Option<String>,
|
pub creation_selector: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -215,7 +223,9 @@ mod tests {
|
|||||||
let summary = WorkingDirectorySummary {
|
let summary = WorkingDirectorySummary {
|
||||||
working_directory_id: "wd-1".to_string(),
|
working_directory_id: "wd-1".to_string(),
|
||||||
display_name: Some("Checkout".to_string()),
|
display_name: Some("Checkout".to_string()),
|
||||||
repository_key: "repo-1".to_string(),
|
source: WorkingDirectorySource::Repository {
|
||||||
|
repository_key: "repo-1".to_string(),
|
||||||
|
},
|
||||||
creation_selector: None,
|
creation_selector: None,
|
||||||
creation_ref: None,
|
creation_ref: None,
|
||||||
creation_tree: None,
|
creation_tree: None,
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ pub struct RepositoryRefObservation {
|
|||||||
pub struct LogicalWorkdirAttachment {
|
pub struct LogicalWorkdirAttachment {
|
||||||
pub alias: workdir::WorkdirAttachmentAlias,
|
pub alias: workdir::WorkdirAttachmentAlias,
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
|
#[serde(default = "default_workdir_session_capabilities")]
|
||||||
|
pub capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
|
||||||
|
workdir::WorkdirSessionCapabilities::ALL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -2409,6 +2409,7 @@ mod tests {
|
|||||||
[LogicalWorkdirAttachment {
|
[LogicalWorkdirAttachment {
|
||||||
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||||
working_directory_id: "remote-workdir".to_string(),
|
working_directory_id: "remote-workdir".to_string(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4293,6 +4293,7 @@ fn logical_workdir_attachments_from_statuses(
|
|||||||
.summary
|
.summary
|
||||||
.working_directory_id
|
.working_directory_id
|
||||||
.clone(),
|
.clone(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -6228,6 +6229,7 @@ mod tests {
|
|||||||
let attachment = LogicalWorkdirAttachment {
|
let attachment = LogicalWorkdirAttachment {
|
||||||
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||||
working_directory_id: "workdir-on-another-runtime".to_string(),
|
working_directory_id: "workdir-on-another-runtime".to_string(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||||
};
|
};
|
||||||
|
|
||||||
let detail = runtime
|
let detail = runtime
|
||||||
|
|||||||
@@ -817,10 +817,11 @@ fn restored_workdir_router(
|
|||||||
router
|
router
|
||||||
.attach(
|
.attach(
|
||||||
attachment.alias.clone(),
|
attachment.alias.clone(),
|
||||||
WorkspaceAttachedWorkdirSession::handle_for_workdir(
|
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
|
||||||
workspace_client.clone(),
|
workspace_client.clone(),
|
||||||
attachment.alias.as_str(),
|
attachment.alias.as_str(),
|
||||||
&attachment.working_directory_id,
|
&attachment.working_directory_id,
|
||||||
|
attachment.capabilities,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -2971,6 +2972,7 @@ mod tests {
|
|||||||
&[LogicalWorkdirAttachment {
|
&[LogicalWorkdirAttachment {
|
||||||
alias: alias.clone(),
|
alias: alias.clone(),
|
||||||
working_directory_id: "remote-workdir".to_string(),
|
working_directory_id: "remote-workdir".to_string(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
}],
|
}],
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
@@ -2979,6 +2981,10 @@ mod tests {
|
|||||||
|
|
||||||
let session = router.session(&alias).unwrap();
|
let session = router.session(&alias).unwrap();
|
||||||
assert_eq!(session.workdir().id().as_str(), "remote-workdir");
|
assert_eq!(session.workdir().id().as_str(), "remote-workdir");
|
||||||
|
assert_eq!(
|
||||||
|
session.capabilities(),
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ pub struct WorkspaceAttachedWorkdirSession {
|
|||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
target_workdir: String,
|
target_workdir: String,
|
||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceAttachedWorkdirSession {
|
impl WorkspaceAttachedWorkdirSession {
|
||||||
@@ -229,11 +230,26 @@ impl WorkspaceAttachedWorkdirSession {
|
|||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
target_workdir: impl Into<String>,
|
target_workdir: impl Into<String>,
|
||||||
workdir_id: impl Into<String>,
|
workdir_id: impl Into<String>,
|
||||||
|
) -> WorkdirSessionHandle {
|
||||||
|
Self::handle_for_workdir_with_capabilities(
|
||||||
|
client,
|
||||||
|
target_workdir,
|
||||||
|
workdir_id,
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_for_workdir_with_capabilities(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
target_workdir: impl Into<String>,
|
||||||
|
workdir_id: impl Into<String>,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
) -> WorkdirSessionHandle {
|
) -> WorkdirSessionHandle {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
client,
|
client,
|
||||||
target_workdir: target_workdir.into(),
|
target_workdir: target_workdir.into(),
|
||||||
workdir: Workdir::new(workdir_id),
|
workdir: Workdir::new(workdir_id),
|
||||||
|
capabilities,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +320,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||||
WorkdirSessionCapabilities::ALL
|
self.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn authorize_scope_path(
|
async fn authorize_scope_path(
|
||||||
@@ -522,10 +538,11 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
let response = self.attach_response(alias, working_directory_id)?;
|
let response = self.attach_response(alias, working_directory_id)?;
|
||||||
if let Err(error) = self.session_router.attach(
|
if let Err(error) = self.session_router.attach(
|
||||||
alias_key.clone(),
|
alias_key.clone(),
|
||||||
WorkspaceAttachedWorkdirSession::handle_for_workdir(
|
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
|
||||||
self.client.clone(),
|
self.client.clone(),
|
||||||
alias,
|
alias,
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
|
response.capabilities,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
let _ = self.detach(alias);
|
let _ = self.detach(alias);
|
||||||
@@ -924,6 +941,7 @@ struct WorkdirAttachmentResponse {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
alias: String,
|
alias: String,
|
||||||
working_directory_id: String,
|
working_directory_id: String,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
attached: bool,
|
attached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,7 +1032,7 @@ mod tests {
|
|||||||
fn workdir_json(id: &str) -> serde_json::Value {
|
fn workdir_json(id: &str) -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"working_directory_id": id,
|
"working_directory_id": id,
|
||||||
"repository_key": "main",
|
"source": {"kind": "repository", "repository_key": "main"},
|
||||||
"creation_selector": "refs/heads/main",
|
"creation_selector": "refs/heads/main",
|
||||||
"creation_ref": "0123456789abcdef",
|
"creation_ref": "0123456789abcdef",
|
||||||
"materializer_kind": "runtime_git_clone",
|
"materializer_kind": "runtime_git_clone",
|
||||||
@@ -1195,12 +1213,14 @@ mod tests {
|
|||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-created",
|
"working_directory_id": "wd-created",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": true
|
"attached": true
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-created",
|
"working_directory_id": "wd-created",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": false
|
"attached": false
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
@@ -1510,6 +1530,7 @@ mod tests {
|
|||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": false
|
"attached": false
|
||||||
}))]));
|
}))]));
|
||||||
let router = Arc::new(workdir::WorkdirSessionRouter::new());
|
let router = Arc::new(workdir::WorkdirSessionRouter::new());
|
||||||
@@ -1582,6 +1603,7 @@ mod tests {
|
|||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": true
|
"attached": true
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
@@ -1596,6 +1618,7 @@ mod tests {
|
|||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": false
|
"attached": false
|
||||||
})),
|
})),
|
||||||
]));
|
]));
|
||||||
@@ -1640,12 +1663,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"workspace_id": "workspace/test",
|
||||||
|
"alias": "external",
|
||||||
|
"working_directory_id": "external-workdir",
|
||||||
|
"capabilities": {"bits": 25},
|
||||||
|
"attached": true
|
||||||
|
}))]));
|
||||||
|
let backend = WorkspaceHttpWorkdirBackend::new(client);
|
||||||
|
backend
|
||||||
|
.attach(WorkdirAttachInput {
|
||||||
|
alias: "external".to_string(),
|
||||||
|
working_directory_id: "external-workdir".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session = backend
|
||||||
|
.session_router
|
||||||
|
.session(&workdir::WorkdirAttachmentAlias::new("external").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
session.capabilities(),
|
||||||
|
WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn successful_attach_reopens_internal_subworker_admission() {
|
async fn successful_attach_reopens_internal_subworker_admission() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
|
"capabilities": {"bits": 63},
|
||||||
"attached": true
|
"attached": true
|
||||||
}))]));
|
}))]));
|
||||||
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|||||||
@@ -668,8 +668,10 @@ CREATE TABLE "workdir_registry" (
|
|||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
workdir_id TEXT NOT NULL,
|
workdir_id TEXT NOT NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
runtime_id TEXT NOT NULL,
|
source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')),
|
||||||
repository_id TEXT NOT NULL,
|
runtime_id TEXT,
|
||||||
|
repository_id TEXT,
|
||||||
|
external_grant_id TEXT,
|
||||||
creation_selector TEXT,
|
creation_selector TEXT,
|
||||||
creation_ref TEXT,
|
creation_ref TEXT,
|
||||||
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
|
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
|
||||||
@@ -679,9 +681,34 @@ CREATE TABLE "workdir_registry" (
|
|||||||
current_selector TEXT,
|
current_selector TEXT,
|
||||||
current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER,
|
current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER,
|
||||||
PRIMARY KEY (workspace_id, workdir_id),
|
PRIMARY KEY (workspace_id, workdir_id),
|
||||||
|
CHECK (
|
||||||
|
(source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL)
|
||||||
|
OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL)
|
||||||
|
),
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (workspace_id, repository_id)
|
FOREIGN KEY (workspace_id, repository_id)
|
||||||
REFERENCES "repositories"(workspace_id, repository_id)
|
REFERENCES "repositories"(workspace_id, repository_id),
|
||||||
|
FOREIGN KEY (workspace_id, external_grant_id)
|
||||||
|
REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED
|
||||||
|
);
|
||||||
|
CREATE TABLE external_workdir_grants (
|
||||||
|
grant_id TEXT NOT NULL,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
workdir_id TEXT NOT NULL,
|
||||||
|
provider_instance_id TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
permissions TEXT NOT NULL CHECK (permissions = 'read_only'),
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL CHECK (generation > 0),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')),
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, grant_id),
|
||||||
|
UNIQUE (workspace_id, workdir_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, workdir_id)
|
||||||
|
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||||
);
|
);
|
||||||
CREATE TABLE workdir_removal_operations (
|
CREATE TABLE workdir_removal_operations (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
@@ -1049,6 +1076,8 @@ CREATE INDEX idx_typed_tickets_workspace_updated
|
|||||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||||
CREATE INDEX idx_workdir_registry_workspace_updated
|
CREATE INDEX idx_workdir_registry_workspace_updated
|
||||||
ON workdir_registry(workspace_id, updated_at DESC);
|
ON workdir_registry(workspace_id, updated_at DESC);
|
||||||
|
CREATE INDEX idx_external_workdir_grants_status_expiry
|
||||||
|
ON external_workdir_grants(workspace_id, status, expires_at);
|
||||||
CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
|
CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
|
||||||
ON workdir_removal_operations(workspace_id, workdir_id)
|
ON workdir_removal_operations(workspace_id, workdir_id)
|
||||||
WHERE state = 'pending';
|
WHERE state = 'pending';
|
||||||
|
|||||||
+1805
-102
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::store::WorkdirRegistryRecord;
|
use crate::store::{WorkdirRegistryRecord, WorkdirRegistrySource};
|
||||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||||
|
|
||||||
const MAX_REASON_BYTES: usize = 500;
|
const MAX_REASON_BYTES: usize = 500;
|
||||||
@@ -95,8 +95,7 @@ pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> St
|
|||||||
let bytes = serde_json::to_vec(&serde_json::json!([
|
let bytes = serde_json::to_vec(&serde_json::json!([
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
record.workdir_id,
|
record.workdir_id,
|
||||||
record.runtime_id,
|
record.source,
|
||||||
record.repository_id,
|
|
||||||
record.creation_selector,
|
record.creation_selector,
|
||||||
record.creation_ref,
|
record.creation_ref,
|
||||||
record.creation_tree,
|
record.creation_tree,
|
||||||
@@ -116,21 +115,29 @@ pub fn workdir_removal_intent(
|
|||||||
let fingerprint_bytes = serde_json::to_vec(&serde_json::json!([
|
let fingerprint_bytes = serde_json::to_vec(&serde_json::json!([
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
record.workdir_id,
|
record.workdir_id,
|
||||||
record.runtime_id,
|
record.source,
|
||||||
record.repository_id,
|
|
||||||
materialization_fingerprint,
|
materialization_fingerprint,
|
||||||
source_actor,
|
source_actor,
|
||||||
reason,
|
reason,
|
||||||
]))
|
]))
|
||||||
.map_err(|error| Error::Store(format!("Workdir removal fingerprint failed: {error}")))?;
|
.map_err(|error| Error::Store(format!("Workdir removal fingerprint failed: {error}")))?;
|
||||||
let request_fingerprint = hex_sha256(&fingerprint_bytes);
|
let request_fingerprint = hex_sha256(&fingerprint_bytes);
|
||||||
|
let WorkdirRegistrySource::Repository {
|
||||||
|
runtime_id,
|
||||||
|
repository_id,
|
||||||
|
} = &record.source
|
||||||
|
else {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"External Workdirs must be revoked through their grant authority".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
Ok(WorkdirRemovalIntent {
|
Ok(WorkdirRemovalIntent {
|
||||||
operation_id: format!("wdr_{}", &request_fingerprint[..32]),
|
operation_id: format!("wdr_{}", &request_fingerprint[..32]),
|
||||||
request_fingerprint,
|
request_fingerprint,
|
||||||
workspace_id: record.workspace_id.clone(),
|
workspace_id: record.workspace_id.clone(),
|
||||||
working_directory_id: record.workdir_id.clone(),
|
working_directory_id: record.workdir_id.clone(),
|
||||||
runtime_id: record.runtime_id.clone(),
|
runtime_id: runtime_id.clone(),
|
||||||
repository_id: record.repository_id.clone(),
|
repository_id: repository_id.clone(),
|
||||||
materialization_fingerprint,
|
materialization_fingerprint,
|
||||||
source_actor: source_actor.to_string(),
|
source_actor: source_actor.to_string(),
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
@@ -682,8 +689,7 @@ fn require_matching_materialization(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if intent.workspace_id != record.workspace_id
|
if intent.workspace_id != record.workspace_id
|
||||||
|| intent.working_directory_id != record.workdir_id
|
|| intent.working_directory_id != record.workdir_id
|
||||||
|| intent.runtime_id != record.runtime_id
|
|| !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &intent.runtime_id && repository_id == &intent.repository_id)
|
||||||
|| intent.repository_id != record.repository_id
|
|
||||||
|| intent.materialization_fingerprint != workdir_materialization_fingerprint(record)
|
|| intent.materialization_fingerprint != workdir_materialization_fingerprint(record)
|
||||||
{
|
{
|
||||||
return Err(Error::WorkdirAttachmentConflict(
|
return Err(Error::WorkdirAttachmentConflict(
|
||||||
@@ -699,8 +705,7 @@ fn require_operation_materialization(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if operation.workspace_id != record.workspace_id
|
if operation.workspace_id != record.workspace_id
|
||||||
|| operation.working_directory_id != record.workdir_id
|
|| operation.working_directory_id != record.workdir_id
|
||||||
|| operation.runtime_id != record.runtime_id
|
|| !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &operation.runtime_id && repository_id == &operation.repository_id)
|
||||||
|| operation.repository_id != record.repository_id
|
|
||||||
|| operation.materialization_fingerprint != workdir_materialization_fingerprint(record)
|
|| operation.materialization_fingerprint != workdir_materialization_fingerprint(record)
|
||||||
{
|
{
|
||||||
return Err(Error::WorkdirAttachmentConflict(
|
return Err(Error::WorkdirAttachmentConflict(
|
||||||
@@ -716,7 +721,7 @@ fn load_workdir_record(
|
|||||||
workdir_id: &str,
|
workdir_id: &str,
|
||||||
) -> Result<Option<WorkdirRegistryRecord>> {
|
) -> Result<Option<WorkdirRegistryRecord>> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
r#"SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id,
|
r#"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id,
|
||||||
creation_selector, creation_ref, creation_tree,
|
creation_selector, creation_ref, creation_tree,
|
||||||
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
|
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
|
||||||
materialization_status, cleanliness, created_at, updated_at
|
materialization_status, cleanliness, created_at, updated_at
|
||||||
@@ -727,19 +732,30 @@ fn load_workdir_record(
|
|||||||
workspace_id: row.get(0)?,
|
workspace_id: row.get(0)?,
|
||||||
workdir_id: row.get(1)?,
|
workdir_id: row.get(1)?,
|
||||||
display_name: row.get(2)?,
|
display_name: row.get(2)?,
|
||||||
runtime_id: row.get(3)?,
|
source: match row.get::<_, String>(3)?.as_str() {
|
||||||
repository_id: row.get(4)?,
|
"repository" => WorkdirRegistrySource::Repository {
|
||||||
creation_selector: row.get(5)?,
|
runtime_id: row.get(4)?,
|
||||||
creation_ref: row.get(6)?,
|
repository_id: row.get(5)?,
|
||||||
creation_tree: row.get(7)?,
|
},
|
||||||
current_selector: row.get(8)?,
|
_ => {
|
||||||
current_ref: row.get(9)?,
|
return Err(rusqlite::Error::InvalidColumnType(
|
||||||
current_tree: row.get(10)?,
|
3,
|
||||||
observed_at_epoch_seconds: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
|
"source_kind".to_string(),
|
||||||
materialization_status: row.get(12)?,
|
rusqlite::types::Type::Text,
|
||||||
cleanliness: row.get(13)?,
|
));
|
||||||
created_at: row.get(14)?,
|
}
|
||||||
updated_at: row.get(15)?,
|
},
|
||||||
|
creation_selector: row.get(6)?,
|
||||||
|
creation_ref: row.get(7)?,
|
||||||
|
creation_tree: row.get(8)?,
|
||||||
|
current_selector: row.get(9)?,
|
||||||
|
current_ref: row.get(10)?,
|
||||||
|
current_tree: row.get(11)?,
|
||||||
|
observed_at_epoch_seconds: row.get::<_, Option<i64>>(12)?.map(|value| value as u64),
|
||||||
|
materialization_status: row.get(13)?,
|
||||||
|
cleanliness: row.get(14)?,
|
||||||
|
created_at: row.get(15)?,
|
||||||
|
updated_at: row.get(16)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -936,8 +952,10 @@ mod tests {
|
|||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
workdir_id: "workdir-a".to_string(),
|
workdir_id: "workdir-a".to_string(),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
runtime_id: "runtime-a".to_string(),
|
source: WorkdirRegistrySource::Repository {
|
||||||
repository_id: "repository-a".to_string(),
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
repository_id: "repository-a".to_string(),
|
||||||
|
},
|
||||||
creation_selector: Some("refs/heads/develop".to_string()),
|
creation_selector: Some("refs/heads/develop".to_string()),
|
||||||
creation_ref: Some("abc".to_string()),
|
creation_ref: Some("abc".to_string()),
|
||||||
creation_tree: Some("tree-a".to_string()),
|
creation_tree: Some("tree-a".to_string()),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::{Error, Result};
|
|||||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"audit_events",
|
"audit_events",
|
||||||
|
"external_workdir_grants",
|
||||||
"flow_source_revisions",
|
"flow_source_revisions",
|
||||||
"flow_sources",
|
"flow_sources",
|
||||||
"memory_staging_records",
|
"memory_staging_records",
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ fn project_working_directory(
|
|||||||
attachments.push(
|
attachments.push(
|
||||||
protocol::subscription::SubscriptionWorkerWorkdirAttachment {
|
protocol::subscription::SubscriptionWorkerWorkdirAttachment {
|
||||||
alias: link.alias,
|
alias: link.alias,
|
||||||
repository_id: Some(workdir.repository_id),
|
repository_id: workdir.source.repository_id().map(str::to_string),
|
||||||
repository_key: None,
|
repository_key: None,
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ e2e-test = ["tui/e2e-test"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
project-record = { workspace = true }
|
project-record = { workspace = true }
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
worker = { workspace = true }
|
worker = { workspace = true }
|
||||||
server-api = { workspace = true }
|
server-api = { workspace = true }
|
||||||
|
workdir = { workspace = true }
|
||||||
|
tokio-tungstenite = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
session-analytics = { workspace = true }
|
session-analytics = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
@@ -23,7 +27,7 @@ tui = { workspace = true }
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod objective_cli;
|
|||||||
mod plugin_cli;
|
mod plugin_cli;
|
||||||
mod session_cli;
|
mod session_cli;
|
||||||
mod ticket_cli;
|
mod ticket_cli;
|
||||||
|
mod workdir_share;
|
||||||
mod worker_cleanup_cli;
|
mod worker_cleanup_cli;
|
||||||
mod workspace_bootstrap;
|
mod workspace_bootstrap;
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ enum Mode {
|
|||||||
no_wait: bool,
|
no_wait: bool,
|
||||||
},
|
},
|
||||||
Init(InitOptions),
|
Init(InitOptions),
|
||||||
|
WorkdirHelp,
|
||||||
|
WorkdirShare(workdir_share::WorkdirShareOptions),
|
||||||
WorkerRuntime(Vec<String>),
|
WorkerRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
SetupModel,
|
SetupModel,
|
||||||
@@ -133,6 +136,17 @@ async fn run(mode: Mode) -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Mode::WorkdirHelp => {
|
||||||
|
print_workdir_help();
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Mode::WorkdirShare(options) => match workdir_share::run(options).await {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("yoi workdir share: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
},
|
||||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
||||||
@@ -420,6 +434,9 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
"--help" | "-h" => return Ok(Mode::Help),
|
"--help" | "-h" => return Ok(Mode::Help),
|
||||||
"resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver),
|
"resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver),
|
||||||
"workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver),
|
"workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver),
|
||||||
|
"workdir" => {
|
||||||
|
return parse_workdir_args(&args[1..], &target_selection);
|
||||||
|
}
|
||||||
"worker" => {
|
"worker" => {
|
||||||
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
|
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
|
||||||
.map_err(|e| ParseError(e.to_string()))?
|
.map_err(|e| ParseError(e.to_string()))?
|
||||||
@@ -611,6 +628,142 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
parse_console_options(args, &target_selection, connection_resolver)
|
parse_console_options(args, &target_selection, connection_resolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_workdir_args(
|
||||||
|
args: &[String],
|
||||||
|
target_selection: &TargetSelection,
|
||||||
|
) -> Result<Mode, ParseError> {
|
||||||
|
if target_selection.explicit_local {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi workdir share requires a Backend target and cannot use --local".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some((subcommand, args)) = args.split_first() else {
|
||||||
|
return Ok(Mode::WorkdirHelp);
|
||||||
|
};
|
||||||
|
if matches!(subcommand.as_str(), "--help" | "-h") {
|
||||||
|
return Ok(Mode::WorkdirHelp);
|
||||||
|
}
|
||||||
|
if subcommand != "share" {
|
||||||
|
return Err(ParseError(format!(
|
||||||
|
"unknown yoi workdir subcommand `{subcommand}`"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut path = None;
|
||||||
|
let mut workspace_id = target_selection.workspace_id.clone();
|
||||||
|
let mut backend_url = target_selection.backend_url.clone();
|
||||||
|
let mut display_name = None;
|
||||||
|
let mut ttl = None;
|
||||||
|
let mut read_only = false;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
match args[index].as_str() {
|
||||||
|
"--workspace-id" => {
|
||||||
|
if workspace_id.is_some() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"--workspace-id may only be provided once".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
workspace_id =
|
||||||
|
Some(required_option_value(args, index, "--workspace-id")?.to_string());
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
"--backend" => {
|
||||||
|
if backend_url.is_some() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"--backend may only be provided once".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
backend_url = Some(required_option_value(args, index, "--backend")?.to_string());
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
"--display-name" => {
|
||||||
|
if display_name.is_some() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"--display-name may only be provided once".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
display_name =
|
||||||
|
Some(required_option_value(args, index, "--display-name")?.to_string());
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
"--ttl" => {
|
||||||
|
if ttl.is_some() {
|
||||||
|
return Err(ParseError("--ttl may only be provided once".to_string()));
|
||||||
|
}
|
||||||
|
ttl = Some(parse_share_ttl(required_option_value(
|
||||||
|
args, index, "--ttl",
|
||||||
|
)?)?);
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
"--read-only" => {
|
||||||
|
if read_only {
|
||||||
|
return Err(ParseError(
|
||||||
|
"--read-only may only be provided once".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
read_only = true;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => return Ok(Mode::WorkdirHelp),
|
||||||
|
option if option.starts_with('-') => {
|
||||||
|
return Err(ParseError(format!(
|
||||||
|
"unknown yoi workdir share option `{option}`"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
value => {
|
||||||
|
if path.is_some() {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi workdir share accepts exactly one directory path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
path = Some(PathBuf::from(value));
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !read_only {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi workdir share requires --read-only".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let workspace_id = workspace_id
|
||||||
|
.ok_or_else(|| ParseError("yoi workdir share requires --workspace-id".to_string()))?;
|
||||||
|
let backend_url = resolve_backend_url(backend_url, Some(&workspace_id))?;
|
||||||
|
Ok(Mode::WorkdirShare(workdir_share::WorkdirShareOptions {
|
||||||
|
path: path
|
||||||
|
.ok_or_else(|| ParseError("yoi workdir share requires a directory path".to_string()))?,
|
||||||
|
workspace_id,
|
||||||
|
backend_url,
|
||||||
|
display_name: display_name
|
||||||
|
.ok_or_else(|| ParseError("yoi workdir share requires --display-name".to_string()))?,
|
||||||
|
ttl: ttl.ok_or_else(|| ParseError("yoi workdir share requires --ttl".to_string()))?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_share_ttl(value: &str) -> Result<Duration, ParseError> {
|
||||||
|
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
|
||||||
|
(value, 60 * 60)
|
||||||
|
} else if let Some(value) = value.strip_suffix('m') {
|
||||||
|
(value, 60)
|
||||||
|
} else if let Some(value) = value.strip_suffix('s') {
|
||||||
|
(value, 1)
|
||||||
|
} else {
|
||||||
|
return Err(ParseError(
|
||||||
|
"--ttl must use an h, m, or s suffix (for example 1h)".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let amount = number.parse::<u64>().map_err(|_| {
|
||||||
|
ParseError("--ttl must contain a positive integer and h, m, or s suffix".to_string())
|
||||||
|
})?;
|
||||||
|
let seconds = amount
|
||||||
|
.checked_mul(multiplier)
|
||||||
|
.ok_or_else(|| ParseError("--ttl is too large".to_string()))?;
|
||||||
|
if !(60..=86_400).contains(&seconds) {
|
||||||
|
return Err(ParseError("--ttl must be between 60s and 24h".to_string()));
|
||||||
|
}
|
||||||
|
Ok(Duration::from_secs(seconds))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_init_args(
|
fn parse_init_args(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
explicit_backend_url: Option<String>,
|
explicit_backend_url: Option<String>,
|
||||||
@@ -1734,6 +1887,7 @@ Usage:
|
|||||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||||
yoi [--backend <URL>] login [--no-wait]
|
yoi [--backend <URL>] login [--no-wait]
|
||||||
|
yoi [--backend <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <NAME>
|
||||||
yoi <HOST_COMMAND> [OPTIONS]
|
yoi <HOST_COMMAND> [OPTIONS]
|
||||||
|
|
||||||
Target selection:
|
Target selection:
|
||||||
@@ -1759,6 +1913,7 @@ Console options:
|
|||||||
|
|
||||||
Host commands:
|
Host commands:
|
||||||
yoi init Register the current Git repository as a new Backend Workspace.
|
yoi init Register the current Git repository as a new Backend Workspace.
|
||||||
|
yoi workdir share Share a temporary read-only client-hosted External Workdir.
|
||||||
keys Manage local model/API keys
|
keys Manage local model/API keys
|
||||||
setup-model Configure a local model provider
|
setup-model Configure a local model provider
|
||||||
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
|
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
|
||||||
@@ -1777,6 +1932,12 @@ Options:
|
|||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
fn print_workdir_help() {
|
||||||
|
println!(
|
||||||
|
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <NAME>\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nNo host path is sent to the Backend. TTL accepts s, m, or h suffixes from 60s through 24h.\nCtrl-C explicitly revokes the grant before exit.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!("{TOP_LEVEL_HELP}");
|
println!("{TOP_LEVEL_HELP}");
|
||||||
}
|
}
|
||||||
@@ -1834,6 +1995,81 @@ fn print_memory_lint_help() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_share_parses_explicit_backend_workspace_and_bounded_ttl() {
|
||||||
|
let args = vec![
|
||||||
|
"share".to_string(),
|
||||||
|
"./session-log".to_string(),
|
||||||
|
"--read-only".to_string(),
|
||||||
|
"--ttl".to_string(),
|
||||||
|
"90m".to_string(),
|
||||||
|
"--display-name".to_string(),
|
||||||
|
"session-analysis".to_string(),
|
||||||
|
];
|
||||||
|
let target = TargetSelection {
|
||||||
|
backend_url: Some("https://backend.example".to_string()),
|
||||||
|
workspace_id: Some("workspace-a".to_string()),
|
||||||
|
..TargetSelection::default()
|
||||||
|
};
|
||||||
|
let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else {
|
||||||
|
panic!("expected Workdir share mode");
|
||||||
|
};
|
||||||
|
assert_eq!(options.path, PathBuf::from("./session-log"));
|
||||||
|
assert_eq!(options.workspace_id, "workspace-a");
|
||||||
|
assert_eq!(options.backend_url, "https://backend.example");
|
||||||
|
assert_eq!(options.display_name, "session-analysis");
|
||||||
|
assert_eq!(options.ttl, Duration::from_secs(5_400));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_share_fails_closed_without_explicit_read_only_workspace_or_valid_ttl() {
|
||||||
|
let base = vec![
|
||||||
|
"share".to_string(),
|
||||||
|
".".to_string(),
|
||||||
|
"--ttl".to_string(),
|
||||||
|
"1h".to_string(),
|
||||||
|
"--display-name".to_string(),
|
||||||
|
"logs".to_string(),
|
||||||
|
];
|
||||||
|
let target = TargetSelection {
|
||||||
|
backend_url: Some("https://backend.example".to_string()),
|
||||||
|
workspace_id: Some("workspace-a".to_string()),
|
||||||
|
..TargetSelection::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
parse_workdir_args(&base, &target)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.contains("--read-only")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut invalid_ttl = base.clone();
|
||||||
|
invalid_ttl.push("--read-only".to_string());
|
||||||
|
invalid_ttl[3] = "25h".to_string();
|
||||||
|
assert!(
|
||||||
|
parse_workdir_args(&invalid_ttl, &target)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.contains("24h")
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing_workspace = TargetSelection {
|
||||||
|
backend_url: Some("https://backend.example".to_string()),
|
||||||
|
..TargetSelection::default()
|
||||||
|
};
|
||||||
|
let mut valid = base;
|
||||||
|
valid.push("--read-only".to_string());
|
||||||
|
assert!(
|
||||||
|
parse_workdir_args(&valid, &missing_workspace)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.contains("--workspace-id")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_parses_connection_before_starting_tokio_runtime() {
|
fn cli_parses_connection_before_starting_tokio_runtime() {
|
||||||
let mut parsed = false;
|
let mut parsed = false;
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use client::BackendApiClient;
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use server_api::{ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse};
|
||||||
|
use tokio_tungstenite::connect_async_with_config;
|
||||||
|
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
|
||||||
|
use workdir::external::{
|
||||||
|
ExternalProviderInstanceId, ExternalWorkdirGrantId, ExternalWorkdirOperationId,
|
||||||
|
ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame, ExternalWorkdirProviderMessage,
|
||||||
|
ExternalWorkdirProviderRegistration, ExternalWorkdirServerFrame, ExternalWorkdirServerMessage,
|
||||||
|
};
|
||||||
|
use workdir::http::{WorkdirTransportError, dispatch_workdir_session_operation};
|
||||||
|
use workdir::{
|
||||||
|
BoundedReadLimits, LocalWorkdirSession, Workdir, WorkdirSession, WorkdirSessionCapabilities,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct WorkdirShareOptions {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub backend_url: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ProviderEnd {
|
||||||
|
Disconnected,
|
||||||
|
Revoked,
|
||||||
|
Interrupted,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OperationCompletion {
|
||||||
|
operation_id: ExternalWorkdirOperationId,
|
||||||
|
outcome: ExternalWorkdirOperationOutcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OperationTask {
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperationTask {
|
||||||
|
fn cancel(&self) {
|
||||||
|
self.cancelled.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cancelled(&self) -> bool {
|
||||||
|
self.cancelled.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for OperationTask {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.cancelled.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||||
|
let metadata = std::fs::symlink_metadata(&options.path)
|
||||||
|
.map_err(|error| format!("selected path is unavailable: {error}"))?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err("selected External Workdir root must not be a symbolic link".to_string());
|
||||||
|
}
|
||||||
|
let canonical = options
|
||||||
|
.path
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("failed to canonicalize selected directory: {error}"))?;
|
||||||
|
if !canonical.is_dir() {
|
||||||
|
return Err("selected External Workdir path must be a directory".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = BackendApiClient::from_stored_token(&options.backend_url)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let provider_instance_id = format!("cli-{}", uuid::Uuid::now_v7());
|
||||||
|
let create_path = format!(
|
||||||
|
"/api/w/{}/external-workdir-grants",
|
||||||
|
encode_path_segment(&options.workspace_id)
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.request(reqwest::Method::POST, &create_path)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.json(&ExternalWorkdirGrantCreateRequest {
|
||||||
|
provider_instance_id: provider_instance_id.clone(),
|
||||||
|
display_name: options.display_name.clone(),
|
||||||
|
ttl_seconds: options.ttl.as_secs(),
|
||||||
|
read_only: true,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("External Workdir grant request failed: {error}"))?;
|
||||||
|
let response = client
|
||||||
|
.require_success(response)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let grant = response
|
||||||
|
.json::<ExternalWorkdirGrantResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))?;
|
||||||
|
|
||||||
|
let limits = BoundedReadLimits::EXTERNAL_DEFAULT;
|
||||||
|
let session = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new(&grant.working_directory_id),
|
||||||
|
&canonical,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
println!("External Workdir grant: {}", grant.grant_id);
|
||||||
|
println!("Logical Workdir: {}", grant.working_directory_id);
|
||||||
|
println!("Display name: {}", grant.display_name);
|
||||||
|
println!("Permission: {}", grant.permissions);
|
||||||
|
println!("Expires at: {}", grant.expires_at);
|
||||||
|
|
||||||
|
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||||
|
.map_err(|_| "Backend returned an invalid External Workdir expiry".to_string())?
|
||||||
|
.with_timezone(&Utc);
|
||||||
|
let mut generation = grant.generation;
|
||||||
|
loop {
|
||||||
|
let outcome = serve_provider_connection(
|
||||||
|
&client,
|
||||||
|
&options,
|
||||||
|
&grant,
|
||||||
|
&provider_instance_id,
|
||||||
|
&session,
|
||||||
|
generation,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let outcome = match outcome {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(connection_error) => {
|
||||||
|
if Utc::now() >= expires_at {
|
||||||
|
return Err(format!(
|
||||||
|
"External Workdir grant expired after provider connection failure: {connection_error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let refresh = fetch_grant(&client, &options.workspace_id, &grant.grant_id);
|
||||||
|
let refreshed = tokio::select! {
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||||
|
revoke(&client, &options.workspace_id, &grant.grant_id).await?;
|
||||||
|
println!("External Workdir grant revoked.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
refreshed = refresh => refreshed,
|
||||||
|
};
|
||||||
|
let current = match refreshed {
|
||||||
|
Ok(current) => current,
|
||||||
|
Err(refresh_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"Connection refresh failed; retrying: {connection_error}; {refresh_error}"
|
||||||
|
);
|
||||||
|
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
println!("External Workdir grant revoked.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match current.status.as_str() {
|
||||||
|
"pending" => generation = current.generation,
|
||||||
|
"offline" => {
|
||||||
|
generation = current.generation.checked_add(1).ok_or_else(|| {
|
||||||
|
"External Workdir provider generation overflow".to_string()
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"online" => {
|
||||||
|
// The Backend may still be observing the old socket close.
|
||||||
|
// Wait for its durable offline fence before reconnecting.
|
||||||
|
}
|
||||||
|
"revoked" | "expired" => {
|
||||||
|
println!("External Workdir grant is no longer active.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
return Err(format!(
|
||||||
|
"Backend returned unknown External Workdir grant status `{status}`"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Connection: reconnecting");
|
||||||
|
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
|
||||||
|
println!("External Workdir grant revoked.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
ProviderEnd::Interrupted => {
|
||||||
|
println!("External Workdir grant revoked.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ProviderEnd::Revoked => {
|
||||||
|
println!("External Workdir grant is no longer active.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ProviderEnd::Disconnected => {
|
||||||
|
if Utc::now() >= expires_at {
|
||||||
|
return Err("External Workdir grant expired while disconnected".to_string());
|
||||||
|
}
|
||||||
|
generation = generation
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| "External Workdir provider generation overflow".to_string())?;
|
||||||
|
println!("Connection: reconnecting");
|
||||||
|
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
|
||||||
|
println!("External Workdir grant revoked.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_provider_connection(
|
||||||
|
client: &BackendApiClient,
|
||||||
|
options: &WorkdirShareOptions,
|
||||||
|
grant: &ExternalWorkdirGrantResponse,
|
||||||
|
provider_instance_id: &str,
|
||||||
|
session: &LocalWorkdirSession,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<ProviderEnd, String> {
|
||||||
|
let provider_path = format!(
|
||||||
|
"/api/w/{}/external-workdir-grants/{}/provider",
|
||||||
|
encode_path_segment(&options.workspace_id),
|
||||||
|
encode_path_segment(&grant.grant_id)
|
||||||
|
);
|
||||||
|
let request = client
|
||||||
|
.websocket_request(&provider_path)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let websocket_config = WebSocketConfig::default()
|
||||||
|
.max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES))
|
||||||
|
.max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES));
|
||||||
|
let connection = connect_async_with_config(request, Some(websocket_config), false);
|
||||||
|
let (mut socket, _) = tokio::select! {
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||||
|
revoke(client, &options.workspace_id, &grant.grant_id).await?;
|
||||||
|
return Ok(ProviderEnd::Interrupted);
|
||||||
|
}
|
||||||
|
connection = connection => connection
|
||||||
|
.map_err(|error| format!("External Workdir provider connection failed: {error}"))?,
|
||||||
|
};
|
||||||
|
let registration =
|
||||||
|
ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register {
|
||||||
|
registration: ExternalWorkdirProviderRegistration {
|
||||||
|
provider_instance_id: ExternalProviderInstanceId::new(provider_instance_id)
|
||||||
|
.map_err(|error| error.to_string())?,
|
||||||
|
grant_id: ExternalWorkdirGrantId::new(grant.grant_id.clone())
|
||||||
|
.map_err(|error| error.to_string())?,
|
||||||
|
workdir_id: session.workdir().id().clone(),
|
||||||
|
generation,
|
||||||
|
capabilities: WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
read_limits: BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
send_provider_frame(&mut socket, ®istration).await?;
|
||||||
|
let registered = tokio::select! {
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||||
|
revoke(client, &options.workspace_id, &grant.grant_id).await?;
|
||||||
|
return Ok(ProviderEnd::Interrupted);
|
||||||
|
}
|
||||||
|
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
|
||||||
|
registered
|
||||||
|
.map_err(|_| "Backend timed out External Workdir provider registration".to_string())?
|
||||||
|
.ok_or_else(|| "Backend closed the External Workdir provider connection".to_string())?
|
||||||
|
.map_err(|error| format!("External Workdir provider registration failed: {error}"))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Message::Text(registered) = registered else {
|
||||||
|
return Err("Backend returned an invalid External Workdir registration frame".to_string());
|
||||||
|
};
|
||||||
|
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(®istered)
|
||||||
|
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
|
||||||
|
if !matches!(
|
||||||
|
frame.message,
|
||||||
|
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
|
||||||
|
if registered_generation == generation
|
||||||
|
) {
|
||||||
|
return Err("Backend rejected the External Workdir provider registration".to_string());
|
||||||
|
}
|
||||||
|
println!("Connection: online (generation {generation})");
|
||||||
|
|
||||||
|
let (completion_sender, mut completions) =
|
||||||
|
tokio::sync::mpsc::channel::<OperationCompletion>(16);
|
||||||
|
let mut operations = HashMap::<String, OperationTask>::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
revoke(client, &options.workspace_id, &grant.grant_id).await?;
|
||||||
|
return Ok(ProviderEnd::Interrupted);
|
||||||
|
}
|
||||||
|
completion = completions.recv() => {
|
||||||
|
let Some(completion) = completion else {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Err("External Workdir operation executor closed".to_string());
|
||||||
|
};
|
||||||
|
let Some(operation) = operations.remove(completion.operation_id.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if operation.is_cancelled() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let result = ExternalWorkdirProviderFrame::current(
|
||||||
|
ExternalWorkdirProviderMessage::OperationResult {
|
||||||
|
generation,
|
||||||
|
operation_id: completion.operation_id,
|
||||||
|
outcome: completion.outcome,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if send_provider_frame(&mut socket, &result).await.is_err() {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Ok(ProviderEnd::Disconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming = socket.next() => {
|
||||||
|
let Some(incoming) = incoming else {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Ok(ProviderEnd::Disconnected);
|
||||||
|
};
|
||||||
|
let incoming = match incoming {
|
||||||
|
Ok(incoming) => incoming,
|
||||||
|
Err(_) => {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Ok(ProviderEnd::Disconnected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match incoming {
|
||||||
|
Message::Text(text) => {
|
||||||
|
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text)
|
||||||
|
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
|
||||||
|
match frame.message {
|
||||||
|
ExternalWorkdirServerMessage::Operation {
|
||||||
|
generation: frame_generation,
|
||||||
|
operation_id,
|
||||||
|
operation,
|
||||||
|
} if frame_generation == generation => {
|
||||||
|
if operations.len() >= 16 || operations.contains_key(operation_id.as_str()) {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Err("Backend exceeded External Workdir operation bounds".to_string());
|
||||||
|
}
|
||||||
|
let key = operation_id.as_str().to_string();
|
||||||
|
let completion_sender = completion_sender.clone();
|
||||||
|
let session = session.clone();
|
||||||
|
let task_operation_id = operation_id.clone();
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let operation = operation.into_inner();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("external-workdir-operation".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
let outcome = futures::executor::block_on(execute_operation(&session, operation));
|
||||||
|
let _ = completion_sender.blocking_send(OperationCompletion {
|
||||||
|
operation_id: task_operation_id,
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to start External Workdir operation: {error}"))?;
|
||||||
|
operations.insert(key, OperationTask { cancelled });
|
||||||
|
}
|
||||||
|
ExternalWorkdirServerMessage::Heartbeat {
|
||||||
|
generation: frame_generation,
|
||||||
|
sequence,
|
||||||
|
} if frame_generation == generation => {
|
||||||
|
let heartbeat = ExternalWorkdirProviderFrame::current(
|
||||||
|
ExternalWorkdirProviderMessage::Heartbeat { generation, sequence },
|
||||||
|
);
|
||||||
|
send_provider_frame(&mut socket, &heartbeat).await?;
|
||||||
|
}
|
||||||
|
ExternalWorkdirServerMessage::Cancel {
|
||||||
|
generation: frame_generation,
|
||||||
|
operation_id,
|
||||||
|
} if frame_generation == generation => {
|
||||||
|
if let Some(operation) = operations.get(operation_id.as_str()) {
|
||||||
|
operation.cancel();
|
||||||
|
let cancelled = ExternalWorkdirProviderFrame::current(
|
||||||
|
ExternalWorkdirProviderMessage::OperationResult {
|
||||||
|
generation,
|
||||||
|
operation_id,
|
||||||
|
outcome: ExternalWorkdirOperationOutcome::Cancelled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
send_provider_frame(&mut socket, &cancelled).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ExternalWorkdirServerMessage::Revoke {
|
||||||
|
generation: frame_generation,
|
||||||
|
..
|
||||||
|
} if frame_generation == generation => {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
let acknowledged = ExternalWorkdirProviderFrame::current(
|
||||||
|
ExternalWorkdirProviderMessage::RevokeAcknowledged { generation },
|
||||||
|
);
|
||||||
|
let _ = send_provider_frame(&mut socket, &acknowledged).await;
|
||||||
|
return Ok(ProviderEnd::Revoked);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Err("stale or unexpected External Workdir provider frame".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Ping(bytes) => {
|
||||||
|
let pong = tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
socket.send(Message::Pong(bytes)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if !matches!(pong, Ok(Ok(()))) {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Ok(ProviderEnd::Disconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => {
|
||||||
|
abort_operations(&mut operations);
|
||||||
|
return Ok(ProviderEnd::Disconnected);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_operation(
|
||||||
|
session: &LocalWorkdirSession,
|
||||||
|
operation: workdir::http::WorkdirSessionOperation,
|
||||||
|
) -> ExternalWorkdirOperationOutcome {
|
||||||
|
match dispatch_workdir_session_operation(session, operation).await {
|
||||||
|
Ok(result) => match result.try_into() {
|
||||||
|
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
||||||
|
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
||||||
|
error: WorkdirTransportError {
|
||||||
|
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(error) => ExternalWorkdirOperationOutcome::Failed {
|
||||||
|
error: WorkdirTransportError::from_workdir_error(&error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort_operations(operations: &mut HashMap<String, OperationTask>) {
|
||||||
|
for (_, operation) in operations.drain() {
|
||||||
|
operation.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_provider_frame<S>(
|
||||||
|
socket: &mut tokio_tungstenite::WebSocketStream<S>,
|
||||||
|
frame: &ExternalWorkdirProviderFrame,
|
||||||
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
let message = Message::Text(
|
||||||
|
serde_json::to_string(frame)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), socket.send(message))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "timed out sending External Workdir provider frame".to_string())?
|
||||||
|
.map_err(|error| format!("failed to send External Workdir provider frame: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_to_reconnect(
|
||||||
|
client: &BackendApiClient,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
tokio::select! {
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||||
|
revoke(client, workspace_id, grant_id).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(250)) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_grant(
|
||||||
|
client: &BackendApiClient,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> Result<ExternalWorkdirGrantResponse, String> {
|
||||||
|
let path = format!(
|
||||||
|
"/api/w/{}/external-workdir-grants/{}",
|
||||||
|
encode_path_segment(workspace_id),
|
||||||
|
encode_path_segment(grant_id)
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.request(reqwest::Method::GET, &path)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("External Workdir grant refresh failed: {error}"))?;
|
||||||
|
client
|
||||||
|
.require_success(response)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.json::<ExternalWorkdirGrantResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(
|
||||||
|
client: &BackendApiClient,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let path = format!(
|
||||||
|
"/api/w/{}/external-workdir-grants/{}",
|
||||||
|
encode_path_segment(workspace_id),
|
||||||
|
encode_path_segment(grant_id)
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.request(reqwest::Method::DELETE, &path)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("External Workdir revoke request failed: {error}"))?;
|
||||||
|
client.require_success(response).await.map_err(|error| {
|
||||||
|
format!("External Workdir revoke was not confirmed by the Backend: {error}")
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_path_segment(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.bytes()
|
||||||
|
.map(|byte| match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
(byte as char).to_string()
|
||||||
|
}
|
||||||
|
_ => format!("%{byte:02X}"),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_segments_are_encoded_without_exposing_structure() {
|
||||||
|
assert_eq!(encode_path_segment("workspace/a b"), "workspace%2Fa%20b");
|
||||||
|
assert_eq!(encode_path_segment("safe-_.~"), "safe-_.~");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
|
|||||||
|
|
||||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||||
|
|
||||||
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
|
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
|
||||||
|
|
||||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||||
|
|
||||||
@@ -13,11 +13,13 @@ export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id
|
|||||||
|
|
||||||
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
||||||
|
|
||||||
|
export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, };
|
||||||
|
|
||||||
export type WorkingDirectorySummary = { working_directory_id: string,
|
export type WorkingDirectorySummary = { working_directory_id: string,
|
||||||
/**
|
/**
|
||||||
* Optional human-facing label, never a routing key.
|
* Optional human-facing label, never a routing key.
|
||||||
*/
|
*/
|
||||||
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
||||||
|
|
||||||
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null,
|
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null,
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,6 @@ display_name?: string | null, repository_key: string, selector?: string | null,
|
|||||||
|
|
||||||
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
|
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
|
||||||
|
|
||||||
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id?: string | null, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||||
|
|
||||||
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
|
|||||||
|
|
||||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||||
|
|
||||||
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
|
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
|
||||||
|
|
||||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||||
|
|
||||||
@@ -23,11 +23,13 @@ display_name?: string | null, repository_id: string, creation_selector?: string
|
|||||||
|
|
||||||
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
|
||||||
|
|
||||||
|
export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, };
|
||||||
|
|
||||||
export type WorkingDirectorySummary = { working_directory_id: string,
|
export type WorkingDirectorySummary = { working_directory_id: string,
|
||||||
/**
|
/**
|
||||||
* Optional human-facing label, never a routing key.
|
* Optional human-facing label, never a routing key.
|
||||||
*/
|
*/
|
||||||
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
|
||||||
|
|
||||||
export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, };
|
export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import type {
|
|||||||
WorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse,
|
||||||
WorkingDirectoryListResponse,
|
WorkingDirectoryListResponse,
|
||||||
WorkingDirectoryOccupancy,
|
WorkingDirectoryOccupancy,
|
||||||
|
WorkingDirectorySource,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
} from "../../generated/workdir-api";
|
} from "../../generated/workdir-api";
|
||||||
|
|
||||||
const SUMMARY_KEYS = new Set([
|
const SUMMARY_KEYS = new Set([
|
||||||
"working_directory_id",
|
"working_directory_id",
|
||||||
"display_name",
|
"display_name",
|
||||||
"repository_key",
|
"source",
|
||||||
"creation_selector",
|
"creation_selector",
|
||||||
"creation_ref",
|
"creation_ref",
|
||||||
"creation_tree",
|
"creation_tree",
|
||||||
@@ -64,13 +65,30 @@ export function parseWorkingDirectoryListResponse(
|
|||||||
export function parseWorkingDirectoryDetailResponse(
|
export function parseWorkingDirectoryDetailResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): WorkingDirectoryDetailResponse {
|
): WorkingDirectoryDetailResponse {
|
||||||
return parseDetailLike(value, "Workdir detail response");
|
const record = parseDetailRecord(value, "Workdir detail response");
|
||||||
|
const response: WorkingDirectoryDetailResponse = {
|
||||||
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
|
item: parseWorkingDirectorySummary(record.item),
|
||||||
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
|
};
|
||||||
|
if (record.runtime_id !== undefined) {
|
||||||
|
response.runtime_id = record.runtime_id === null
|
||||||
|
? null
|
||||||
|
: stringField(record, "runtime_id");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWorkingDirectoryCreateResponse(
|
export function parseWorkingDirectoryCreateResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): WorkingDirectoryCreateResponse {
|
): WorkingDirectoryCreateResponse {
|
||||||
return parseDetailLike(value, "Workdir create response");
|
const record = parseDetailRecord(value, "Workdir create response");
|
||||||
|
return {
|
||||||
|
workspace_id: stringField(record, "workspace_id"),
|
||||||
|
runtime_id: stringField(record, "runtime_id"),
|
||||||
|
item: parseWorkingDirectorySummary(record.item),
|
||||||
|
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateWorkingDirectoryCreateRequest(
|
export function validateWorkingDirectoryCreateRequest(
|
||||||
@@ -91,21 +109,15 @@ export function validateWorkingDirectoryCreateRequest(
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDetailLike(
|
function parseDetailRecord(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
label: string,
|
label: string,
|
||||||
): WorkingDirectoryDetailResponse {
|
): Record<string, unknown> {
|
||||||
const record = exactRecord(
|
return exactRecord(
|
||||||
value,
|
value,
|
||||||
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
|
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
|
||||||
label,
|
label,
|
||||||
);
|
);
|
||||||
return {
|
|
||||||
workspace_id: stringField(record, "workspace_id"),
|
|
||||||
runtime_id: stringField(record, "runtime_id"),
|
|
||||||
item: parseWorkingDirectorySummary(record.item),
|
|
||||||
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWorkingDirectorySummary(
|
export function parseWorkingDirectorySummary(
|
||||||
@@ -114,9 +126,10 @@ export function parseWorkingDirectorySummary(
|
|||||||
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
|
||||||
const summary: WorkingDirectorySummary = {
|
const summary: WorkingDirectorySummary = {
|
||||||
working_directory_id: stringField(record, "working_directory_id"),
|
working_directory_id: stringField(record, "working_directory_id"),
|
||||||
repository_key: stringField(record, "repository_key"),
|
source: parseWorkingDirectorySource(record.source),
|
||||||
materializer_kind: enumField(record, "materializer_kind", [
|
materializer_kind: enumField(record, "materializer_kind", [
|
||||||
"runtime_git_clone",
|
"runtime_git_clone",
|
||||||
|
"client_hosted_external",
|
||||||
]),
|
]),
|
||||||
status: enumField(record, "status", [
|
status: enumField(record, "status", [
|
||||||
"active",
|
"active",
|
||||||
@@ -160,6 +173,36 @@ export function parseWorkingDirectorySummary(
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||||
|
const source = exactRecord(
|
||||||
|
value,
|
||||||
|
new Set(["kind", "repository_key", "grant_id"]),
|
||||||
|
"Workdir source",
|
||||||
|
);
|
||||||
|
const kind = stringField(source, "kind");
|
||||||
|
if (kind === "repository") {
|
||||||
|
if (source.grant_id !== undefined) {
|
||||||
|
throw new Error("Repository Workdir source must not contain grant_id");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
repository_key: stringField(source, "repository_key"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === "external_grant") {
|
||||||
|
if (source.repository_key !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"External Workdir source must not contain repository_key",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
grant_id: stringField(source, "grant_id"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Workdir source.kind has an unsupported value");
|
||||||
|
}
|
||||||
|
|
||||||
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
||||||
const record = exactRecord(
|
const record = exactRecord(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function workdir(
|
|||||||
): WorkingDirectorySummary {
|
): WorkingDirectorySummary {
|
||||||
return {
|
return {
|
||||||
working_directory_id: "workdir-1",
|
working_directory_id: "workdir-1",
|
||||||
repository_key: "repository-1",
|
source: { kind: "repository", repository_key: "repository-1" },
|
||||||
current_selector,
|
current_selector,
|
||||||
current_ref,
|
current_ref,
|
||||||
materializer_kind: "runtime_git_clone",
|
materializer_kind: "runtime_git_clone",
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi
|
|||||||
assertEquals(form.working_directory_selector, "HEAD");
|
assertEquals(form.working_directory_selector, "HEAD");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm preserves an available read-only External Workdir", () => {
|
||||||
|
const external = {
|
||||||
|
working_directory_id: "external-1",
|
||||||
|
display_name: "Session analysis",
|
||||||
|
source: { kind: "external_grant" as const, grant_id: "grant-1" },
|
||||||
|
materializer_kind: "client_hosted_external" as const,
|
||||||
|
status: "active" as const,
|
||||||
|
cleanliness: "unknown",
|
||||||
|
};
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
{ ...options, working_directories: [external] },
|
||||||
|
emptyForm({
|
||||||
|
workdir_attachments: [{
|
||||||
|
alias: "sessions",
|
||||||
|
working_directory_id: "external-1",
|
||||||
|
relative_cwd: "",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.workdir_attachments, [{
|
||||||
|
alias: "sessions",
|
||||||
|
working_directory_id: "external-1",
|
||||||
|
relative_cwd: "",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
||||||
const reviewerOptions = {
|
const reviewerOptions = {
|
||||||
...options,
|
...options,
|
||||||
@@ -87,7 +114,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
|
|||||||
{
|
{
|
||||||
...options.working_directories[0],
|
...options.working_directories[0],
|
||||||
working_directory_id: "ticket-workdir",
|
working_directory_id: "ticket-workdir",
|
||||||
repository_key: "ticket-repo",
|
source: { kind: "repository", repository_key: "ticket-repo" },
|
||||||
creation_selector: "work/ticket",
|
creation_selector: "work/ticket",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -318,7 +345,7 @@ const options: WorkerLaunchOptionsResponse = {
|
|||||||
working_directories: [
|
working_directories: [
|
||||||
{
|
{
|
||||||
working_directory_id: "wd-1-repo",
|
working_directory_id: "wd-1-repo",
|
||||||
repository_key: "repo",
|
source: { kind: "repository", repository_key: "repo" },
|
||||||
creation_selector: "HEAD",
|
creation_selector: "HEAD",
|
||||||
creation_ref: "0123456789abcdef",
|
creation_ref: "0123456789abcdef",
|
||||||
current_selector: null,
|
current_selector: null,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export function defaultWorkerLaunchForm(
|
|||||||
const availableWorkingDirectories =
|
const availableWorkingDirectories =
|
||||||
options?.working_directories.filter((directory) =>
|
options?.working_directories.filter((directory) =>
|
||||||
directory.status === "active" &&
|
directory.status === "active" &&
|
||||||
directory.cleanliness === "clean" &&
|
(directory.source.kind === "external_grant" ||
|
||||||
|
directory.cleanliness === "clean") &&
|
||||||
directory.occupied_by == null
|
directory.occupied_by == null
|
||||||
) ?? [];
|
) ?? [];
|
||||||
const selectedRuntime = current.runtime_id
|
const selectedRuntime = current.runtime_id
|
||||||
@@ -54,13 +55,17 @@ export function defaultWorkerLaunchForm(
|
|||||||
? undefined
|
? undefined
|
||||||
: availableWorkingDirectories.find((directory) =>
|
: availableWorkingDirectories.find((directory) =>
|
||||||
Boolean(current.working_directory_repository_key) &&
|
Boolean(current.working_directory_repository_key) &&
|
||||||
directory.repository_key === current.working_directory_repository_key &&
|
directory.source.kind === "repository" &&
|
||||||
|
directory.source.repository_key ===
|
||||||
|
current.working_directory_repository_key &&
|
||||||
(!current.working_directory_selector ||
|
(!current.working_directory_selector ||
|
||||||
(directory.current_selector ?? directory.creation_selector) ===
|
(directory.current_selector ?? directory.creation_selector) ===
|
||||||
current.working_directory_selector)
|
current.working_directory_selector)
|
||||||
) ?? availableWorkingDirectories.find((directory) =>
|
) ?? availableWorkingDirectories.find((directory) =>
|
||||||
Boolean(current.working_directory_repository_key) &&
|
Boolean(current.working_directory_repository_key) &&
|
||||||
directory.repository_key === current.working_directory_repository_key
|
directory.source.kind === "repository" &&
|
||||||
|
directory.source.repository_key ===
|
||||||
|
current.working_directory_repository_key
|
||||||
) ?? (current.working_directory_repository_key
|
) ?? (current.working_directory_repository_key
|
||||||
? undefined
|
? undefined
|
||||||
: availableWorkingDirectories[0]);
|
: availableWorkingDirectories[0]);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function workdir(
|
|||||||
): WorkingDirectorySummary {
|
): WorkingDirectorySummary {
|
||||||
return {
|
return {
|
||||||
working_directory_id: "workdir-1234567890abcdef",
|
working_directory_id: "workdir-1234567890abcdef",
|
||||||
repository_key: "main",
|
source: { kind: "repository", repository_key: "main" },
|
||||||
materializer_kind: "runtime_git_clone",
|
materializer_kind: "runtime_git_clone",
|
||||||
status: "active",
|
status: "active",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -39,7 +39,9 @@ function attachment(
|
|||||||
const workingDirectory = workdir(overrides);
|
const workingDirectory = workdir(overrides);
|
||||||
return {
|
return {
|
||||||
alias,
|
alias,
|
||||||
repository_key: workingDirectory.repository_key,
|
repository_key: workingDirectory.source.kind === "repository"
|
||||||
|
? workingDirectory.source.repository_key
|
||||||
|
: null,
|
||||||
working_directory_id: workingDirectory.working_directory_id,
|
working_directory_id: workingDirectory.working_directory_id,
|
||||||
working_directory: workingDirectory,
|
working_directory: workingDirectory,
|
||||||
};
|
};
|
||||||
@@ -105,13 +107,30 @@ Deno.test("Worker sidebar Workdir label has an explicit unavailable-projection f
|
|||||||
assertEquals(meta.text, "main:unknown@workdir-1234");
|
assertEquals(meta.text, "main:unknown@workdir-1234");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir label uses an External Workdir display name without fake repository metadata", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([
|
||||||
|
attachment("sessions", {
|
||||||
|
display_name: "Session analysis",
|
||||||
|
source: { kind: "external_grant", grant_id: "grant-1" },
|
||||||
|
materializer_kind: "client_hosted_external",
|
||||||
|
cleanliness: "unknown",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "Session analysis");
|
||||||
|
assert(
|
||||||
|
meta.details.includes("sessions — Session analysis"),
|
||||||
|
"alias should remain visible",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker sidebar Workdir metadata preserves long names for truncated display details", () => {
|
Deno.test("Worker sidebar Workdir metadata preserves long names for truncated display details", () => {
|
||||||
const repository = "repository-with-a-name-that-exceeds-the-sidebar-width";
|
const repository = "repository-with-a-name-that-exceeds-the-sidebar-width";
|
||||||
const branch =
|
const branch =
|
||||||
"feature/workdir-label-with-a-name-that-also-exceeds-the-sidebar-width";
|
"feature/workdir-label-with-a-name-that-also-exceeds-the-sidebar-width";
|
||||||
const meta = sidebarWorkdirMeta([
|
const meta = sidebarWorkdirMeta([
|
||||||
attachment("long-checkout", {
|
attachment("long-checkout", {
|
||||||
repository_key: repository,
|
source: { kind: "repository", repository_key: repository },
|
||||||
current_selector: branch,
|
current_selector: branch,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -128,7 +147,7 @@ Deno.test("Worker sidebar Workdir metadata keeps multiple attachments distinguis
|
|||||||
attachment("frontend", { current_selector: "develop" }),
|
attachment("frontend", { current_selector: "develop" }),
|
||||||
attachment("backend", {
|
attachment("backend", {
|
||||||
working_directory_id: "workdir-fedcba0987654321",
|
working_directory_id: "workdir-fedcba0987654321",
|
||||||
repository_key: "server",
|
source: { kind: "repository", repository_key: "server" },
|
||||||
current_selector: "release",
|
current_selector: "release",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ export function sidebarWorkdirMeta(
|
|||||||
|
|
||||||
function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
|
function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
|
||||||
const workdir = attachment.working_directory;
|
const workdir = attachment.working_directory;
|
||||||
const repository = clean(workdir?.repository_key) ??
|
if (workdir?.source.kind === "external_grant") {
|
||||||
clean(attachment.repository_key) ?? "unknown-repo";
|
return clean(workdir.display_name) ?? "external";
|
||||||
return `${repository}:${revisionLabel(attachment)}`;
|
}
|
||||||
|
const repository = workdir?.source.kind === "repository"
|
||||||
|
? clean(workdir.source.repository_key)
|
||||||
|
: clean(attachment.repository_key);
|
||||||
|
return `${repository ?? "unknown-repo"}:${revisionLabel(attachment)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function revisionLabel(attachment: SidebarWorkdirAttachment): string {
|
function revisionLabel(attachment: SidebarWorkdirAttachment): string {
|
||||||
|
|||||||
+9
-3
@@ -24,9 +24,15 @@
|
|||||||
workdirs = data.workdirs?.items ?? [];
|
workdirs = data.workdirs?.items ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
|
||||||
|
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
|
||||||
|
}
|
||||||
|
|
||||||
function repositoryProvider(workdir: WorkingDirectorySummary): string | null {
|
function repositoryProvider(workdir: WorkingDirectorySummary): string | null {
|
||||||
return data.repositories?.items.find((repository) => repository.repository_key === workdir.repository_key)
|
const key = repositoryKey(workdir);
|
||||||
?.provider ?? null;
|
return key
|
||||||
|
? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentRevision(workdir: WorkingDirectorySummary): string {
|
function currentRevision(workdir: WorkingDirectorySummary): string {
|
||||||
@@ -142,7 +148,7 @@
|
|||||||
<span>{workdir.display_name ?? '—'}</span>
|
<span>{workdir.display_name ?? '—'}</span>
|
||||||
<small><code>{workdir.working_directory_id}</code></small>
|
<small><code>{workdir.working_directory_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td>{workdir.repository_key}</td>
|
<td>{repositoryKey(workdir) ?? 'External'}</td>
|
||||||
<td><code>{currentRevision(workdir)}</code></td>
|
<td><code>{currentRevision(workdir)}</code></td>
|
||||||
<td>{workdir.status}</td>
|
<td>{workdir.status}</td>
|
||||||
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
<td>{workdir.cleanliness ?? 'unknown'}</td>
|
||||||
|
|||||||
@@ -148,9 +148,13 @@
|
|||||||
const attachments = worker.workdir_attachments ?? [];
|
const attachments = worker.workdir_attachments ?? [];
|
||||||
if (attachments.length === 0) return '—';
|
if (attachments.length === 0) return '—';
|
||||||
return attachments.map(({ alias, working_directory: directory }) => {
|
return attachments.map(({ alias, working_directory: directory }) => {
|
||||||
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
|
const repositoryKey = directory.source.kind === 'repository'
|
||||||
?.provider;
|
? directory.source.repository_key
|
||||||
const label = directory.display_name ?? directory.repository_key;
|
: null;
|
||||||
|
const provider = repositoryKey
|
||||||
|
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
|
||||||
|
: null;
|
||||||
|
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||||
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
|
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
|
||||||
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
|
const repositoryKey = directory.source.kind === 'repository'
|
||||||
?.provider;
|
? directory.source.repository_key
|
||||||
const label = directory.display_name ?? directory.repository_key;
|
: null;
|
||||||
|
const provider = repositoryKey
|
||||||
|
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
|
||||||
|
: null;
|
||||||
|
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||||
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
? []
|
? []
|
||||||
: (options?.working_directories ?? []).filter((directory) =>
|
: (options?.working_directories ?? []).filter((directory) =>
|
||||||
directory.status === 'active' &&
|
directory.status === 'active' &&
|
||||||
directory.cleanliness === 'clean' &&
|
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
|
||||||
directory.occupied_by == null
|
directory.occupied_by == null
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
parseWorkingDirectoryCreateResponse,
|
parseWorkingDirectoryCreateResponse,
|
||||||
|
parseWorkingDirectoryDetailResponse,
|
||||||
parseWorkingDirectoryListResponse,
|
parseWorkingDirectoryListResponse,
|
||||||
validateWorkingDirectoryCreateRequest,
|
validateWorkingDirectoryCreateRequest,
|
||||||
} from "../src/lib/workspace/api/workdirs.ts";
|
} from "../src/lib/workspace/api/workdirs.ts";
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
const summary = {
|
const summary = {
|
||||||
working_directory_id: "workdir-1",
|
working_directory_id: "workdir-1",
|
||||||
display_name: "Checkout",
|
display_name: "Checkout",
|
||||||
repository_key: "main",
|
source: { kind: "repository", repository_key: "main" },
|
||||||
materializer_kind: "runtime_git_clone",
|
materializer_kind: "runtime_git_clone",
|
||||||
status: "active",
|
status: "active",
|
||||||
occupied_by: {
|
occupied_by: {
|
||||||
@@ -46,6 +47,27 @@ Deno.test("Workdir REST validation accepts the generated list and create contrac
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Workdir REST validation accepts External grant sources without Runtime identity", () => {
|
||||||
|
const detail = parseWorkingDirectoryDetailResponse({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
item: {
|
||||||
|
working_directory_id: "external-1",
|
||||||
|
display_name: "Session analysis",
|
||||||
|
source: { kind: "external_grant", grant_id: "grant-1" },
|
||||||
|
materializer_kind: "client_hosted_external",
|
||||||
|
status: "active",
|
||||||
|
cleanliness: "unknown",
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
if (detail.runtime_id !== undefined) {
|
||||||
|
throw new Error("External Workdir acquired a fake Runtime identity");
|
||||||
|
}
|
||||||
|
if (detail.item.source.kind !== "external_grant") {
|
||||||
|
throw new Error("External Workdir source was not preserved");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Workdir REST validation rejects stale response JSON", () => {
|
Deno.test("Workdir REST validation rejects stale response JSON", () => {
|
||||||
let rejected = false;
|
let rejected = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user