648170b224
新增 command.output_read 并按当前 Agent 的持久动作身份分页读取命令输出 为 command.exec 写入受限 transcript sidecar 并保持审计与回执零正文 完善隔离 Agent 模板提示、失败结果收束和重复 spawn 去重 将真实 Provider E2E 改为无固定配方的结果导向验收 同步共享命令与能力契约并补齐 Rust 和 TypeScript 测试 更新 Runtime 技术方案、实施计划和共享决策记录
608 lines
22 KiB
Rust
608 lines
22 KiB
Rust
use super::*;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
pub(crate) const COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION: &str = "game-creator-command-output.v1";
|
||
pub(crate) const COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES: usize = 256 * 1024;
|
||
pub(crate) const COMMAND_OUTPUT_READ_DEFAULT_LINES: usize = 160;
|
||
pub(crate) const COMMAND_OUTPUT_READ_MAX_LINES: usize = 240;
|
||
|
||
const COMMAND_OUTPUT_TRANSCRIPT_DIR: &str = ".agent/runtime/command-outputs";
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct CommandOutputIdentity {
|
||
pub(crate) agent_id: String,
|
||
pub(crate) task_id: String,
|
||
pub(crate) session_id: String,
|
||
pub(crate) run_id: String,
|
||
pub(crate) action_id: String,
|
||
pub(crate) action_fingerprint: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct CommandOutputTranscript {
|
||
pub(crate) schema_version: String,
|
||
pub(crate) identity: CommandOutputIdentity,
|
||
pub(crate) output_ref: String,
|
||
pub(crate) command_id: String,
|
||
pub(crate) program: String,
|
||
pub(crate) args_sha256: String,
|
||
pub(crate) args_count: usize,
|
||
pub(crate) cwd: String,
|
||
pub(crate) exit_code: Option<i32>,
|
||
pub(crate) timed_out: bool,
|
||
pub(crate) duration_ms: u64,
|
||
pub(crate) source_changed: bool,
|
||
pub(crate) capture_truncated: bool,
|
||
pub(crate) output_sha256: String,
|
||
pub(crate) total_lines: usize,
|
||
pub(crate) output: String,
|
||
pub(crate) updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct CommandOutputPage {
|
||
pub(crate) source_action_id: String,
|
||
pub(crate) source_run_id: String,
|
||
pub(crate) source_action_fingerprint: String,
|
||
pub(crate) output_ref: String,
|
||
pub(crate) lines: String,
|
||
pub(crate) start_line: usize,
|
||
pub(crate) next_line: Option<usize>,
|
||
pub(crate) total_lines: usize,
|
||
pub(crate) has_more: bool,
|
||
pub(crate) capture_truncated: bool,
|
||
pub(crate) output_sha256: String,
|
||
pub(crate) exit_code: Option<i32>,
|
||
pub(crate) timed_out: bool,
|
||
pub(crate) source_changed: bool,
|
||
}
|
||
|
||
pub(crate) fn command_output_relative_path(identity: &CommandOutputIdentity) -> String {
|
||
let mut digest = Sha256::new();
|
||
for value in [
|
||
identity.agent_id.as_str(),
|
||
identity.task_id.as_str(),
|
||
identity.session_id.as_str(),
|
||
identity.run_id.as_str(),
|
||
identity.action_id.as_str(),
|
||
] {
|
||
digest.update(value.as_bytes());
|
||
digest.update([0]);
|
||
}
|
||
format!(
|
||
"{COMMAND_OUTPUT_TRANSCRIPT_DIR}/{:x}.json",
|
||
digest.finalize()
|
||
)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn build_command_output_transcript(
|
||
identity: CommandOutputIdentity,
|
||
command_id: &str,
|
||
program: &str,
|
||
arguments: &[String],
|
||
cwd: &str,
|
||
exit_code: Option<i32>,
|
||
timed_out: bool,
|
||
duration_ms: u64,
|
||
source_changed: bool,
|
||
capture_truncated: bool,
|
||
output: &str,
|
||
updated_at: u64,
|
||
) -> Result<CommandOutputTranscript, String> {
|
||
validate_command_output_identity(&identity)?;
|
||
validate_command_output_safe_text(command_id, "commandId", 160)?;
|
||
validate_command_output_safe_text(program, "program", 40)?;
|
||
let cwd = if cwd == "." {
|
||
".".to_string()
|
||
} else {
|
||
normalize_relative_path(cwd)?
|
||
};
|
||
let output = sanitize_project_verification_output(output);
|
||
let output_sha256 = format!("{:x}", Sha256::digest(output.as_bytes()));
|
||
let total_lines = command_output_line_count(&output);
|
||
let args = serde_json::to_vec(arguments)
|
||
.map_err(|error| format!("序列化 command.exec argv 摘要失败:{error}"))?;
|
||
let transcript = CommandOutputTranscript {
|
||
schema_version: COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION.to_string(),
|
||
output_ref: command_output_relative_path(&identity),
|
||
identity,
|
||
command_id: command_id.to_string(),
|
||
program: program.to_string(),
|
||
args_sha256: format!("{:x}", Sha256::digest(args)),
|
||
args_count: arguments.len(),
|
||
cwd,
|
||
exit_code,
|
||
timed_out,
|
||
duration_ms,
|
||
source_changed,
|
||
capture_truncated,
|
||
output_sha256,
|
||
total_lines,
|
||
output,
|
||
updated_at,
|
||
};
|
||
validate_command_output_transcript(&transcript)?;
|
||
Ok(transcript)
|
||
}
|
||
|
||
pub(crate) fn write_command_output_transcript_at(
|
||
root: &Path,
|
||
transcript: &CommandOutputTranscript,
|
||
) -> Result<(), String> {
|
||
validate_command_output_transcript(transcript)?;
|
||
let mut content = serde_json::to_vec_pretty(transcript)
|
||
.map_err(|error| format!("序列化 command.exec 输出 sidecar 失败:{error}"))?;
|
||
content.push(b'\n');
|
||
if content.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES {
|
||
return Err(format!(
|
||
"command.exec 输出 sidecar 超过 {} 字节上限",
|
||
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
|
||
));
|
||
}
|
||
let path = resolve_local_project_path(root, &transcript.output_ref)?;
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|error| {
|
||
format!(
|
||
"创建 command.exec 输出 sidecar 目录失败:{}: {error}",
|
||
parent.display()
|
||
)
|
||
})?;
|
||
}
|
||
let path = resolve_local_project_path(root, &transcript.output_ref)?;
|
||
match fs::symlink_metadata(&path) {
|
||
Ok(metadata) => {
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("command.exec 输出 sidecar 必须是普通文件".to_string());
|
||
}
|
||
let existing = read_command_output_transcript_file(&path)?;
|
||
if existing == *transcript {
|
||
return Ok(());
|
||
}
|
||
return Err("command.exec 输出 sidecar 已存在且身份或内容冲突".to_string());
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 command.exec 输出 sidecar 元数据失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = match options.open(&path) {
|
||
Ok(file) => file,
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||
let existing = read_command_output_transcript_file(&path)?;
|
||
if existing == *transcript {
|
||
return Ok(());
|
||
}
|
||
return Err("command.exec 输出 sidecar 并发创建后内容冲突".to_string());
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"创建 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
};
|
||
file.write_all(&content).map_err(|error| {
|
||
format!(
|
||
"写入 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
file.sync_all().map_err(|error| {
|
||
format!(
|
||
"同步 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
validate_command_output_file_handle(&file, &path)?;
|
||
drop(file);
|
||
let installed = read_command_output_transcript_file(&path)?;
|
||
if installed != *transcript {
|
||
return Err("command.exec 输出 sidecar 安装后内容不一致".to_string());
|
||
}
|
||
#[cfg(unix)]
|
||
if let Some(parent) = path.parent() {
|
||
File::open(parent)
|
||
.and_then(|directory| directory.sync_all())
|
||
.map_err(|error| {
|
||
format!(
|
||
"同步 command.exec 输出 sidecar 目录失败:{}: {error}",
|
||
parent.display()
|
||
)
|
||
})?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn read_command_output_page_at(
|
||
root: &Path,
|
||
expected: &CommandOutputIdentity,
|
||
start_line: usize,
|
||
max_lines: usize,
|
||
) -> Result<CommandOutputPage, String> {
|
||
validate_command_output_identity(expected)?;
|
||
if start_line == 0 {
|
||
return Err("command.output_read 的 startLine 必须从 1 开始".to_string());
|
||
}
|
||
if max_lines == 0 || max_lines > COMMAND_OUTPUT_READ_MAX_LINES {
|
||
return Err(format!(
|
||
"command.output_read 的 maxLines 必须在 1-{} 之间",
|
||
COMMAND_OUTPUT_READ_MAX_LINES
|
||
));
|
||
}
|
||
let output_ref = command_output_relative_path(expected);
|
||
let path = resolve_local_project_path(root, &output_ref)?;
|
||
let transcript = read_command_output_transcript_file(&path)?;
|
||
validate_command_output_transcript(&transcript)?;
|
||
if transcript.identity != *expected || transcript.output_ref != output_ref {
|
||
return Err("command.output_read 的 sidecar 身份与源动作不匹配".to_string());
|
||
}
|
||
if transcript.total_lines == 0 {
|
||
if start_line != 1 {
|
||
return Err("command.output_read 的 startLine 超出空输出范围".to_string());
|
||
}
|
||
return Ok(CommandOutputPage {
|
||
source_action_id: expected.action_id.clone(),
|
||
source_run_id: expected.run_id.clone(),
|
||
source_action_fingerprint: expected.action_fingerprint.clone(),
|
||
output_ref,
|
||
lines: String::new(),
|
||
start_line,
|
||
next_line: None,
|
||
total_lines: 0,
|
||
has_more: false,
|
||
capture_truncated: transcript.capture_truncated,
|
||
output_sha256: transcript.output_sha256,
|
||
exit_code: transcript.exit_code,
|
||
timed_out: transcript.timed_out,
|
||
source_changed: transcript.source_changed,
|
||
});
|
||
}
|
||
if start_line > transcript.total_lines {
|
||
return Err(format!(
|
||
"command.output_read 的 startLine 超出总行数 {}",
|
||
transcript.total_lines
|
||
));
|
||
}
|
||
let end_line = start_line
|
||
.saturating_add(max_lines)
|
||
.saturating_sub(1)
|
||
.min(transcript.total_lines);
|
||
let lines = transcript
|
||
.output
|
||
.split('\n')
|
||
.enumerate()
|
||
.skip(start_line - 1)
|
||
.take(end_line - start_line + 1)
|
||
.map(|(index, line)| format!("{}: {line}", index + 1))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let has_more = end_line < transcript.total_lines;
|
||
Ok(CommandOutputPage {
|
||
source_action_id: expected.action_id.clone(),
|
||
source_run_id: expected.run_id.clone(),
|
||
source_action_fingerprint: expected.action_fingerprint.clone(),
|
||
output_ref,
|
||
lines,
|
||
start_line,
|
||
next_line: has_more.then_some(end_line + 1),
|
||
total_lines: transcript.total_lines,
|
||
has_more,
|
||
capture_truncated: transcript.capture_truncated,
|
||
output_sha256: transcript.output_sha256,
|
||
exit_code: transcript.exit_code,
|
||
timed_out: transcript.timed_out,
|
||
source_changed: transcript.source_changed,
|
||
})
|
||
}
|
||
|
||
fn read_command_output_transcript_file(path: &Path) -> Result<CommandOutputTranscript, String> {
|
||
let (mut file, metadata) =
|
||
open_project_snapshot_regular_file(path, "command.exec 输出 sidecar")?;
|
||
if metadata.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES as u64 {
|
||
return Err(format!(
|
||
"command.exec 输出 sidecar 超过 {} 字节上限",
|
||
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
|
||
));
|
||
}
|
||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||
std::io::Read::by_ref(&mut file)
|
||
.take((COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES + 1) as u64)
|
||
.read_to_end(&mut bytes)
|
||
.map_err(|error| {
|
||
format!(
|
||
"读取 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if bytes.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES {
|
||
return Err(format!(
|
||
"command.exec 输出 sidecar 超过 {} 字节上限",
|
||
COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES
|
||
));
|
||
}
|
||
let final_metadata = file.metadata().map_err(|error| {
|
||
format!(
|
||
"复核 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if final_metadata.len() != metadata.len() {
|
||
return Err("command.exec 输出 sidecar 在读取期间发生漂移".to_string());
|
||
}
|
||
validate_command_output_file_handle(&file, path)?;
|
||
serde_json::from_slice(&bytes).map_err(|error| {
|
||
format!(
|
||
"解析 command.exec 输出 sidecar 失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})
|
||
}
|
||
|
||
fn validate_command_output_transcript(transcript: &CommandOutputTranscript) -> Result<(), String> {
|
||
if transcript.schema_version != COMMAND_OUTPUT_TRANSCRIPT_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的 command.exec 输出 sidecar schema:{}",
|
||
transcript.schema_version
|
||
));
|
||
}
|
||
validate_command_output_identity(&transcript.identity)?;
|
||
if transcript.output_ref != command_output_relative_path(&transcript.identity)
|
||
|| !transcript
|
||
.output_ref
|
||
.starts_with(&format!("{COMMAND_OUTPUT_TRANSCRIPT_DIR}/"))
|
||
|| !transcript.output_ref.ends_with(".json")
|
||
{
|
||
return Err("command.exec 输出 sidecar 的 outputRef 无效".to_string());
|
||
}
|
||
validate_command_output_safe_text(&transcript.command_id, "commandId", 160)?;
|
||
validate_command_output_safe_text(&transcript.program, "program", 40)?;
|
||
if transcript.cwd != "." {
|
||
normalize_relative_path(&transcript.cwd)?;
|
||
}
|
||
validate_sha256(&transcript.args_sha256, "argsSha256")?;
|
||
validate_sha256(&transcript.output_sha256, "outputSha256")?;
|
||
let sanitized = sanitize_project_verification_output(&transcript.output);
|
||
if sanitized != transcript.output
|
||
|| format!("{:x}", Sha256::digest(transcript.output.as_bytes())) != transcript.output_sha256
|
||
|| command_output_line_count(&transcript.output) != transcript.total_lines
|
||
{
|
||
return Err("command.exec 输出 sidecar 的正文摘要或行数无效".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_command_output_identity(identity: &CommandOutputIdentity) -> Result<(), String> {
|
||
validate_command_output_safe_text(&identity.agent_id, "agentId", 96)?;
|
||
validate_command_output_safe_text(&identity.task_id, "taskId", 96)?;
|
||
validate_command_output_safe_text(&identity.session_id, "sessionId", 160)?;
|
||
validate_command_output_safe_text(&identity.run_id, "runId", 160)?;
|
||
if !identity
|
||
.action_id
|
||
.strip_prefix("action-")
|
||
.is_some_and(|suffix| {
|
||
suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
})
|
||
{
|
||
return Err("command.exec 输出 sidecar 的 actionId 无效".to_string());
|
||
}
|
||
validate_sha256(&identity.action_fingerprint, "actionFingerprint")
|
||
}
|
||
|
||
fn validate_command_output_safe_text(
|
||
value: &str,
|
||
field: &str,
|
||
max_chars: usize,
|
||
) -> Result<(), String> {
|
||
if value.trim().is_empty()
|
||
|| value.chars().count() > max_chars
|
||
|| value.chars().any(char::is_control)
|
||
{
|
||
return Err(format!("command.exec 输出 sidecar 的 {field} 无效"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_sha256(value: &str, field: &str) -> Result<(), String> {
|
||
if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("command.exec 输出 sidecar 的 {field} 无效"))
|
||
}
|
||
}
|
||
|
||
pub(crate) fn command_output_line_count(output: &str) -> usize {
|
||
if output.is_empty() {
|
||
0
|
||
} else {
|
||
output.split('\n').count()
|
||
}
|
||
}
|
||
|
||
fn validate_command_output_file_handle(file: &File, path: &Path) -> Result<(), String> {
|
||
let metadata = file.metadata().map_err(|error| {
|
||
format!(
|
||
"读取 command.exec 输出 sidecar 句柄失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if !metadata.is_file() {
|
||
return Err("command.exec 输出 sidecar 必须是普通文件".to_string());
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::MetadataExt;
|
||
if metadata.nlink() != 1 {
|
||
return Err("command.exec 输出 sidecar 不能是硬链接".to_string());
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
crate::runner::validate_windows_regular_file_handle(file, "command.exec 输出 sidecar")?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn output_project(name: &str) -> tempfile::TempDir {
|
||
let dir = tempfile::Builder::new()
|
||
.prefix(&format!("command-output-{name}-"))
|
||
.tempdir()
|
||
.expect("command output tempdir");
|
||
init_local_game_project_at(dir.path(), "command-output-project", "命令输出测试")
|
||
.expect("init command output project");
|
||
dir
|
||
}
|
||
|
||
fn identity(seed: &str) -> CommandOutputIdentity {
|
||
CommandOutputIdentity {
|
||
agent_id: "code-prototype".to_string(),
|
||
task_id: "code-prototype".to_string(),
|
||
session_id: format!("session-{seed}"),
|
||
run_id: format!("run-{seed}"),
|
||
action_id: format!("action-{:024x}", seed.len()),
|
||
action_fingerprint: format!("{:064x}", seed.len()),
|
||
}
|
||
}
|
||
|
||
fn transcript(seed: &str, output: &str) -> CommandOutputTranscript {
|
||
build_command_output_transcript(
|
||
identity(seed),
|
||
"command.exec.node.--test",
|
||
"node",
|
||
&["--test".to_string(), "test/sample.test.mjs".to_string()],
|
||
".",
|
||
Some(1),
|
||
false,
|
||
25,
|
||
false,
|
||
true,
|
||
output,
|
||
1,
|
||
)
|
||
.expect("build transcript")
|
||
}
|
||
|
||
#[test]
|
||
fn command_output_paginates_unicode_with_one_based_lines() {
|
||
let dir = output_project("page");
|
||
let transcript = transcript("page", "第一行\nsecond\n第三行");
|
||
write_command_output_transcript_at(dir.path(), &transcript).expect("write transcript");
|
||
let first = read_command_output_page_at(dir.path(), &transcript.identity, 1, 2)
|
||
.expect("first page");
|
||
assert_eq!(first.lines, "1: 第一行\n2: second");
|
||
assert_eq!(first.next_line, Some(3));
|
||
assert!(first.has_more);
|
||
assert!(first.capture_truncated);
|
||
let last =
|
||
read_command_output_page_at(dir.path(), &transcript.identity, 3, 2).expect("last page");
|
||
assert_eq!(last.lines, "3: 第三行");
|
||
assert_eq!(last.next_line, None);
|
||
assert!(!last.has_more);
|
||
}
|
||
|
||
#[test]
|
||
fn command_output_write_is_immutable_and_idempotent() {
|
||
let dir = output_project("immutable");
|
||
let transcript = transcript("immutable", "stable output");
|
||
write_command_output_transcript_at(dir.path(), &transcript).expect("first write");
|
||
write_command_output_transcript_at(dir.path(), &transcript).expect("idempotent write");
|
||
let mut conflict = transcript.clone();
|
||
conflict.output = "changed".to_string();
|
||
conflict.output_sha256 = format!("{:x}", Sha256::digest(conflict.output.as_bytes()));
|
||
conflict.total_lines = 1;
|
||
assert!(write_command_output_transcript_at(dir.path(), &conflict)
|
||
.expect_err("reject conflict")
|
||
.contains("冲突"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_output_rejects_identity_and_corrupt_content() {
|
||
let dir = output_project("identity");
|
||
let transcript = transcript("identity", "output");
|
||
write_command_output_transcript_at(dir.path(), &transcript).expect("write transcript");
|
||
let mut other = transcript.identity.clone();
|
||
other.run_id = "run-other".to_string();
|
||
assert!(read_command_output_page_at(dir.path(), &other, 1, 1).is_err());
|
||
fs::write(
|
||
resolve_local_project_path(dir.path(), &transcript.output_ref).expect("output path"),
|
||
b"{broken",
|
||
)
|
||
.expect("corrupt transcript");
|
||
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 1, 1).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn command_output_handles_empty_output_and_rejects_page_limits() {
|
||
let dir = output_project("empty");
|
||
let transcript = transcript("empty", "");
|
||
write_command_output_transcript_at(dir.path(), &transcript)
|
||
.expect("write empty transcript");
|
||
let page = read_command_output_page_at(dir.path(), &transcript.identity, 1, 1)
|
||
.expect("read empty transcript");
|
||
assert!(page.lines.is_empty());
|
||
assert_eq!(page.total_lines, 0);
|
||
assert_eq!(page.next_line, None);
|
||
assert!(!page.has_more);
|
||
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 0, 1).is_err());
|
||
assert!(read_command_output_page_at(
|
||
dir.path(),
|
||
&transcript.identity,
|
||
1,
|
||
COMMAND_OUTPUT_READ_MAX_LINES + 1,
|
||
)
|
||
.is_err());
|
||
|
||
let path = resolve_local_project_path(dir.path(), &transcript.output_ref).expect("path");
|
||
fs::write(&path, vec![b'x'; COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES + 1])
|
||
.expect("write oversized transcript");
|
||
assert!(
|
||
read_command_output_page_at(dir.path(), &transcript.identity, 1, 1)
|
||
.expect_err("reject oversized transcript")
|
||
.contains("字节上限")
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn command_output_rejects_symlink_and_hardlink_targets() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let dir = output_project("links");
|
||
let transcript = transcript("links", "output");
|
||
let path = resolve_local_project_path(dir.path(), &transcript.output_ref).expect("path");
|
||
fs::create_dir_all(path.parent().expect("parent")).expect("create output dir");
|
||
let outside = dir.path().join("outside.json");
|
||
fs::write(&outside, b"{}\n").expect("write outside");
|
||
symlink(&outside, &path).expect("create symlink");
|
||
assert!(write_command_output_transcript_at(dir.path(), &transcript).is_err());
|
||
fs::remove_file(&path).expect("remove symlink");
|
||
fs::hard_link(&outside, &path).expect("create hardlink");
|
||
assert!(read_command_output_page_at(dir.path(), &transcript.identity, 1, 1).is_err());
|
||
fs::remove_file(&path).expect("remove hardlink");
|
||
symlink(dir.path().join("missing.json"), &path).expect("create dangling symlink");
|
||
assert!(write_command_output_transcript_at(dir.path(), &transcript).is_err());
|
||
}
|
||
}
|