M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
//! Filesystem extension ops (`workspace.fs_*`) — the server-proxied backing
|
||||
//! for the shell's `x.ai/fs/*` ACP extension methods.
|
||||
//!
|
||||
//! These mirror the pure functions that previously lived only in the
|
||||
//! shell (`kigi-shell/src/session/file_system.rs`) so that, in proxy
|
||||
//! mode, a `x.ai/fs/*` request executes on the *remote* workspace server
|
||||
//! instead of the agent host. Each request type implements
|
||||
//! [`WorkspaceOp`], so it runs in-process for local sessions and routes
|
||||
//! over the server `workspace_rpc` tool for proxy sessions — identical wire
|
||||
//! output either way.
|
||||
//!
|
||||
//! Path resolution: an absolute `path` is used directly; a relative
|
||||
//! `path` is joined onto `cwd` (the per-session cwd the shell resolves
|
||||
//! and sends) or, when absent, the workspace root.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::error::{WorkspaceError, WorkspaceResult};
|
||||
use crate::handle::WorkspaceHandle;
|
||||
use crate::workspace_ops::WorkspaceOp;
|
||||
|
||||
// Canonical in kigi-workspace-types; re-exported for existing paths.
|
||||
use kigi_workspace_types::rpc::fs::FsReadEncoding;
|
||||
pub use kigi_workspace_types::rpc::fs::{
|
||||
FsDeleteFileReq, FsExistsData, FsExistsReq, FsListData, FsListNode, FsListReq, FsReadFileData,
|
||||
FsReadFileReq, FsWriteFileReq,
|
||||
};
|
||||
|
||||
/// Resolve a request `path` to an absolute path. Absolute paths are used
|
||||
/// directly; relative paths join `cwd` (the shell-resolved per-session
|
||||
/// cwd) or, when absent, the workspace root.
|
||||
fn resolve_abs(
|
||||
path: &str,
|
||||
cwd: &Option<PathBuf>,
|
||||
ws: &WorkspaceHandle,
|
||||
) -> WorkspaceResult<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
let base = match cwd {
|
||||
Some(c) => c.clone(),
|
||||
None => ws.root_cwd()?,
|
||||
};
|
||||
Ok(base.join(p))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for FsListReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
|
||||
let (abs, confine_root) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
// Off-executor: `list` does synchronous walk + metadata syscalls.
|
||||
let req = self.clone();
|
||||
tokio::task::spawn_blocking(move || list(&abs, &req, confine_root))
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for FsExistsReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
|
||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
let exists = tokio::fs::try_exists(&abs).await.unwrap_or(false);
|
||||
Ok(FsExistsData { exists })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for FsReadFileReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
|
||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
|
||||
// Legacy full-file read path: preserves the pre-range wire output
|
||||
// (auto utf8/base64 detect, MIME `type`, `lineCount`).
|
||||
let ranged = self.offset.is_some()
|
||||
|| self.length.is_some()
|
||||
|| self.encoding == FsReadEncoding::Base64;
|
||||
if !ranged {
|
||||
let bytes = tokio::fs::read(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
return Ok(build_file_entry(&bytes));
|
||||
}
|
||||
|
||||
// Binary-safe ranged read: `size` is the full file size, the
|
||||
// chunk is `[offset, offset + min(length, max_bytes, cap))`.
|
||||
let md = tokio::fs::metadata(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
if md.is_dir() {
|
||||
return Err(WorkspaceError::HubError(format!(
|
||||
"not a file: {}",
|
||||
self.path
|
||||
)));
|
||||
}
|
||||
// Best-effort snapshot: a concurrent truncate/grow between here and
|
||||
// read_range can make `size` inconsistent with the returned chunk.
|
||||
let size = md.len();
|
||||
let offset = self.offset.unwrap_or(0);
|
||||
let length = super::walk::clamp_read_length(self.length, self.max_bytes);
|
||||
let chunk = super::walk::read_range(&abs, offset, length)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
Ok(build_ranged_entry(chunk, size, self.encoding))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for FsWriteFileReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
|
||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
let content = self.content.clone();
|
||||
let create_dirs = self.create_dirs;
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
if create_dirs && let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&abs, content.as_bytes())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for FsDeleteFileReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
|
||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
tokio::fs::remove_file(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pure helpers — ported verbatim from the shell so output is identical.
|
||||
// =========================================================================
|
||||
|
||||
fn list(
|
||||
abs_path: &Path,
|
||||
req: &FsListReq,
|
||||
confine_to_canonical_root: Option<PathBuf>,
|
||||
) -> WorkspaceResult<FsListData> {
|
||||
// Confined to the canonical root when set (escaping symlinks not enumerated);
|
||||
// `None` (the default) walks unconfined.
|
||||
let page = super::walk::list_directory_paged(
|
||||
abs_path,
|
||||
super::walk::ListOptions {
|
||||
depth: req.depth,
|
||||
follow_symlinks: req.follow_symlinks,
|
||||
respect_git_ignore: req.respect_git_ignore,
|
||||
include_hidden: req.include_hidden,
|
||||
include_globs: &req.include_globs,
|
||||
exclude_globs: &req.exclude_globs,
|
||||
offset: req.offset,
|
||||
limit: req.limit,
|
||||
confine_to_canonical_root,
|
||||
},
|
||||
super::walk::MAX_LIST_COLLECT,
|
||||
);
|
||||
|
||||
let nodes: Vec<FsListNode> = page
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| FsListNode {
|
||||
node_type: if e.is_dir { "directory" } else { "file" }.to_string(),
|
||||
size: e.size,
|
||||
modified_at: e.modified.map(|st| {
|
||||
let dt: chrono::DateTime<Utc> = st.into();
|
||||
dt.to_rfc3339()
|
||||
}),
|
||||
is_symlink: e.is_symlink.then_some(true),
|
||||
path: e.abs_path.to_string_lossy().into_owned(),
|
||||
name: e.name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(FsListData {
|
||||
nodes,
|
||||
truncated: page.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a binary-safe ranged chunk to the shell-facing `FsReadFileData`.
|
||||
/// `size` is the full file size; `lineCount` is omitted for ranged reads
|
||||
/// and the MIME `type` is a coarse text/binary tag (mid-file chunks make
|
||||
/// magic-byte sniffing meaningless).
|
||||
fn build_ranged_entry(chunk: Vec<u8>, size: u64, encoding: FsReadEncoding) -> FsReadFileData {
|
||||
let (payload, is_text) = super::walk::encode_chunk(chunk, encoding);
|
||||
let (content, content_base64) = match payload {
|
||||
super::walk::ChunkPayload::Text(t) => (t, None),
|
||||
super::walk::ChunkPayload::Base64(b) => (String::new(), Some(b)),
|
||||
};
|
||||
FsReadFileData {
|
||||
content,
|
||||
content_base64,
|
||||
size,
|
||||
line_count: None,
|
||||
content_type: if is_text {
|
||||
"text/plain".to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_file_entry(bytes: &[u8]) -> FsReadFileData {
|
||||
let size = bytes.len() as u64;
|
||||
let inferred = infer::get(bytes).map(|t| t.mime_type().to_string());
|
||||
match String::from_utf8(bytes.to_vec()) {
|
||||
Ok(text) => FsReadFileData {
|
||||
line_count: Some(text.lines().count() as u64),
|
||||
content: text,
|
||||
content_base64: None,
|
||||
size,
|
||||
content_type: inferred.unwrap_or_else(|| "text/plain".to_string()),
|
||||
},
|
||||
Err(_) => FsReadFileData {
|
||||
content: String::new(),
|
||||
content_base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
size,
|
||||
line_count: None,
|
||||
content_type: inferred.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests for the pure helpers (no `WorkspaceHandle` required).
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build an `FsListReq` for a temp dir test. `path`/`cwd` are unused by
|
||||
/// `list` (it takes the resolved abs path directly); `respect_git_ignore`
|
||||
/// is off so the temp dir's location can't filter out our fixtures.
|
||||
fn list_req(limit: usize) -> FsListReq {
|
||||
FsListReq {
|
||||
path: String::new(),
|
||||
cwd: None,
|
||||
depth: 1,
|
||||
limit,
|
||||
offset: 0,
|
||||
include_hidden: true,
|
||||
follow_symlinks: true,
|
||||
respect_git_ignore: false,
|
||||
include_globs: Vec::new(),
|
||||
exclude_globs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_file_entry_utf8_sets_content_and_line_count() {
|
||||
let bytes = b"line one\nline two\n";
|
||||
let entry = build_file_entry(bytes);
|
||||
assert_eq!(entry.content, "line one\nline two\n");
|
||||
assert!(entry.content_base64.is_none());
|
||||
assert_eq!(entry.line_count, Some(2));
|
||||
assert_eq!(entry.size, bytes.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_file_entry_invalid_utf8_uses_base64() {
|
||||
let bytes: &[u8] = &[0xff, 0xfe, 0x00];
|
||||
let entry = build_file_entry(bytes);
|
||||
assert!(entry.content.is_empty());
|
||||
assert_eq!(
|
||||
entry.content_base64,
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
);
|
||||
assert!(entry.line_count.is_none());
|
||||
assert_eq!(entry.size, bytes.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_enumerates_nodes_without_truncation() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("a.txt"), b"a").expect("write a");
|
||||
std::fs::write(root.join("b.txt"), b"bb").expect("write b");
|
||||
std::fs::create_dir(root.join("sub")).expect("mkdir sub");
|
||||
|
||||
let data = list(root, &list_req(1000), Some(root.to_path_buf())).expect("list");
|
||||
|
||||
let names: Vec<&str> = data.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(data.nodes.len(), 3, "names: {names:?}");
|
||||
assert!(names.contains(&"a.txt"));
|
||||
assert!(names.contains(&"b.txt"));
|
||||
assert!(names.contains(&"sub"));
|
||||
assert!(!data.truncated);
|
||||
// Directories sort ahead of files.
|
||||
assert_eq!(data.nodes[0].name, "sub");
|
||||
assert_eq!(data.nodes[0].node_type, "directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marks_truncated_when_limit_reached() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("a.txt"), b"a").expect("write a");
|
||||
std::fs::write(root.join("b.txt"), b"bb").expect("write b");
|
||||
std::fs::create_dir(root.join("sub")).expect("mkdir sub");
|
||||
|
||||
let data = list(root, &list_req(1), Some(root.to_path_buf())).expect("list");
|
||||
assert_eq!(data.nodes.len(), 1);
|
||||
assert!(data.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_paginates_with_offset() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
for n in ["a.txt", "b.txt", "c.txt", "d.txt"] {
|
||||
std::fs::write(root.join(n), b"x").expect("write");
|
||||
}
|
||||
let names = |d: FsListData| d.nodes.into_iter().map(|n| n.name).collect::<Vec<_>>();
|
||||
|
||||
let mut req = list_req(2);
|
||||
let p0 = list(root, &req, Some(root.to_path_buf())).expect("list");
|
||||
assert!(p0.truncated);
|
||||
assert_eq!(names(p0), vec!["a.txt", "b.txt"]);
|
||||
|
||||
req.offset = 2;
|
||||
let p1 = list(root, &req, Some(root.to_path_buf())).expect("list");
|
||||
assert!(!p1.truncated, "last page is not truncated");
|
||||
assert_eq!(names(p1), vec!["c.txt", "d.txt"]);
|
||||
|
||||
// Offset past the end yields an empty, non-truncated page.
|
||||
req.offset = 10;
|
||||
let p2 = list(root, &req, Some(root.to_path_buf())).expect("list");
|
||||
assert!(!p2.truncated);
|
||||
assert!(p2.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ranged_entry_encodes_utf8_and_binary() {
|
||||
// UTF-8 chunk under the default encoding → `content`, text/plain,
|
||||
// full `size` echoed, no line count.
|
||||
let e = build_ranged_entry(b"hello".to_vec(), 100, FsReadEncoding::Utf8);
|
||||
assert_eq!(e.content, "hello");
|
||||
assert!(e.content_base64.is_none());
|
||||
assert_eq!(e.size, 100);
|
||||
assert!(e.line_count.is_none());
|
||||
assert_eq!(e.content_type, "text/plain");
|
||||
|
||||
// Explicit base64 of valid UTF-8 stays text/plain but travels in
|
||||
// `contentBase64`.
|
||||
let e = build_ranged_entry(b"hi".to_vec(), 2, FsReadEncoding::Base64);
|
||||
assert!(e.content.is_empty());
|
||||
assert_eq!(
|
||||
e.content_base64,
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(b"hi")),
|
||||
);
|
||||
assert_eq!(e.content_type, "text/plain");
|
||||
|
||||
// Non-UTF-8 bytes fall back to base64 + octet-stream.
|
||||
let raw = vec![0xff_u8, 0x00, 0xfe];
|
||||
let e = build_ranged_entry(raw.clone(), 3, FsReadEncoding::Utf8);
|
||||
assert!(e.content.is_empty());
|
||||
assert_eq!(
|
||||
e.content_base64,
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&raw)),
|
||||
);
|
||||
assert_eq!(e.content_type, "application/octet-stream");
|
||||
}
|
||||
|
||||
// Confinement (WorkspaceOp::execute) — covers both local and proxy dispatch.
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_write_within_root_ok() {
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let root = ws.root_cwd().unwrap();
|
||||
FsWriteFileReq {
|
||||
path: "sub/data.txt".into(),
|
||||
cwd: Some(root.clone()),
|
||||
content: "hello".into(),
|
||||
create_dirs: true,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect("in-root write must succeed");
|
||||
let data = FsReadFileReq {
|
||||
path: "sub/data.txt".into(),
|
||||
cwd: Some(root.clone()),
|
||||
offset: None,
|
||||
length: None,
|
||||
max_bytes: 1 << 20,
|
||||
encoding: FsReadEncoding::Utf8,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect("in-root read must succeed");
|
||||
assert_eq!(data.content, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_rejects_absolute_escape() {
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let err = FsReadFileReq {
|
||||
path: "/etc/passwd".into(),
|
||||
cwd: None,
|
||||
offset: None,
|
||||
length: None,
|
||||
max_bytes: 1 << 20,
|
||||
encoding: FsReadEncoding::Utf8,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect_err("absolute escape must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("workspace root"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn read_file_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let root = ws.root_cwd().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
|
||||
symlink(outside.path(), root.join("escape_link")).unwrap();
|
||||
|
||||
let err = FsReadFileReq {
|
||||
path: "escape_link/secret.txt".into(),
|
||||
cwd: Some(root.clone()),
|
||||
offset: None,
|
||||
length: None,
|
||||
max_bytes: 1 << 20,
|
||||
encoding: FsReadEncoding::Utf8,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect_err("symlink escape must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("workspace root"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn write_file_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let root = ws.root_cwd().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), root.join("escape_link")).unwrap();
|
||||
|
||||
let err = FsWriteFileReq {
|
||||
path: "escape_link/injected.txt".into(),
|
||||
cwd: Some(root.clone()),
|
||||
content: "x".into(),
|
||||
create_dirs: true,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect_err("symlink escape write must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("workspace root"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(
|
||||
!outside.path().join("injected.txt").exists(),
|
||||
"write must not land outside the workspace root"
|
||||
);
|
||||
}
|
||||
|
||||
// A *dangling* in-root symlink (target outside root, not yet created) must
|
||||
// not let a write escape via `open(O_CREAT)` following the link.
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn write_file_rejects_dangling_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let root = ws.root_cwd().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let target = outside.path().join("new.txt");
|
||||
symlink(&target, root.join("lnk")).unwrap();
|
||||
|
||||
let err = FsWriteFileReq {
|
||||
path: "lnk".into(),
|
||||
cwd: Some(root.clone()),
|
||||
content: "x".into(),
|
||||
create_dirs: true,
|
||||
}
|
||||
.execute(&ws, None)
|
||||
.await
|
||||
.expect_err("dangling symlink escape write must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("workspace root"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"write must not create the file outside root"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn list_excludes_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let ws = crate::handle::tests::make_confining_handle();
|
||||
let root = ws.root_cwd().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
|
||||
symlink(outside.path(), root.join("escape_link")).unwrap();
|
||||
std::fs::write(root.join("inside.txt"), b"y").unwrap();
|
||||
|
||||
let mut req = list_req(1000);
|
||||
req.path = ".".into();
|
||||
req.cwd = Some(root.clone());
|
||||
req.depth = 2;
|
||||
let data = req.execute(&ws, None).await.expect("list must succeed");
|
||||
let names: Vec<&str> = data.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert!(names.contains(&"inside.txt"), "in-root file: {names:?}");
|
||||
assert!(
|
||||
!data.nodes.iter().any(|n| n.path.contains("secret.txt")),
|
||||
"escaping symlink target must not be enumerated: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user