blob: 131311a1906735ebf0ee4822ebea254ed4d78df7 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! File-path display from the original TUI link resolver. No file is opened.
use maka_agent_ports::session::history::HistorySource;
use maka_agent_ports::session::history::SourceField;
use maka_agent_ports::session::history::SourceFormat;
use maka_agent_ports::session::history::SourceRole;
use maka_agent_ports::session::history::SourceSegment;
use std::path::Path;
fn valid(path: &str) -> bool {
!path.trim().is_empty()
&& path.len() <= 4096
&& !path.contains("://")
&& !path.chars().any(|ch| ch.is_control() || matches!(ch, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'))
}
fn absolute(path: &str, cwd: &str) -> Option<String> {
if !valid(path) {
return None;
}
if Path::new(path).is_absolute() {
return Some(path.into());
}
if !valid(cwd) || !Path::new(cwd).is_absolute() {
return None;
}
// Preserve `..`: canonicalizing or lexical normalization changes symlinks.
let joined = Path::new(cwd).join(path).to_str()?.to_owned();
(valid(&joined) && Path::new(&joined).is_absolute()).then_some(joined)
}
fn selector(source: &HistorySource, expected: &[&str]) -> bool {
source.path.as_deref().is_some_and(|path| {
path.len() == expected.len()
&& path
.iter()
.zip(expected)
.all(|(part, name)| matches!(part, SourceSegment::Key(key) if key == name))
})
}
pub(crate) fn file_path(source: &HistorySource, workspace: &str) -> Option<String> {
if source.format != SourceFormat::Text
|| source.offset != 0
|| source.text.len() as u64 != source.total_bytes
{
return None;
}
let tool = source.tool.as_ref()?;
let call = tool.call.as_ref()?;
let key = call.presentation.as_deref()?;
let patch = match key {
"maka.workspace.Files@1/readText"
| "maka.workspace.Files@1/write"
| "maka.workspace.Files@1/edit" => false,
"maka.workspace.Files@1/applyPatch" => true,
_ => return None,
};
let arguments = source.role == SourceRole::ToolCall
&& source.field == SourceField::Arguments
&& source.position == call.position
&& selector(
source,
if patch {
&["operation", "path"]
} else {
&["path"]
},
);
let result = source.role == SourceRole::ToolResult
&& source.field == SourceField::Result
&& matches!(
key,
"maka.workspace.Files@1/write" | "maka.workspace.Files@1/edit"
)
&& selector(source, &["value", "path"])
&& tool
.result
.as_ref()
.is_some_and(|result| result.position == source.position && !result.is_error);
(arguments || result)
.then(|| absolute(&source.text, workspace))
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn source() -> HistorySource {
serde_json::from_value(json!({
"position":{"sourceSequence":4,"itemIndex":0},"timestamp":1,"role":"tool_call","fields":["arguments"],
"field":"arguments","path":["path"],"format":"text","offset":0,
"totalBytes":8,"text":"test.txt","tool":{"toolUseId":"call",
"call":{"ancestorToolUseIds":[],"position":{"sourceSequence":4,"itemIndex":0},"name":"arbitrary alias","title":null,"pending":false,"dispatched":true,
"presentation":"maka.workspace.Files@1/write"},
"result":{"position":{"sourceSequence":5,"itemIndex":0},"isError":false,"image":false,"settlement":"returned"}}
}))
.unwrap()
}
#[test]
fn literal_paths_use_host_workspace_without_canonicalizing_or_opening() {
let cwd = std::env::temp_dir().join("missing-host-workspace");
let cwd = cwd.to_str().unwrap();
let name = "目录/../it's $(literal); 🦀.rs";
assert_eq!(
absolute(name, cwd),
Some(Path::new(cwd).join(name).to_str().unwrap().into())
);
assert_eq!(absolute(cwd, "relative"), Some(cwd.into()));
assert_eq!(absolute(name, "relative"), None);
for invalid in [
"",
" ",
"https://example.test/a",
"maka://read/task",
"a\n",
"a\0",
"a\u{202e}rs",
"a\x1b[31m",
] {
assert_eq!(absolute(invalid, cwd), None);
}
assert_eq!(absolute(&"界".repeat(1366), cwd), None);
assert_eq!(absolute(&"x".repeat(4090), cwd), None);
}
#[test]
fn only_complete_explicit_file_evidence_can_publish_a_copy_path() {
let cwd = std::env::temp_dir();
let cwd = cwd.to_str().unwrap();
let base = source();
assert_eq!(file_path(&base, cwd), absolute("test.txt", cwd));
for variant in 0..9 {
let mut source = base.clone();
match variant {
0 => {
source
.tool
.as_mut()
.unwrap()
.call
.as_mut()
.unwrap()
.presentation = None
}
1 => {
source
.tool
.as_mut()
.unwrap()
.call
.as_mut()
.unwrap()
.presentation = Some("custom/files".into())
}
2 => source.offset = 1,
3 => source.total_bytes += 1,
4 => source.format = SourceFormat::Json,
5 => source.path = None,
6 => source.path = Some(vec![SourceSegment::Key("content".into())]),
7 => source.field = SourceField::Result,
_ => source.position.source_sequence = 99,
}
assert_eq!(file_path(&source, cwd), None, "variant {variant}");
}
let mut patch = base.clone();
patch
.tool
.as_mut()
.unwrap()
.call
.as_mut()
.unwrap()
.presentation = Some("maka.workspace.Files@1/applyPatch".into());
assert_eq!(file_path(&patch, cwd), None);
patch.path = Some(vec![
SourceSegment::Key("operation".into()),
SourceSegment::Key("path".into()),
]);
assert_eq!(file_path(&patch, cwd), absolute("test.txt", cwd));
let mut result = base;
result.role = SourceRole::ToolResult;
result.field = SourceField::Result;
result.position.source_sequence = 5;
result.path = Some(vec![
SourceSegment::Key("value".into()),
SourceSegment::Key("path".into()),
]);
assert_eq!(file_path(&result, cwd), absolute("test.txt", cwd));
result
.tool
.as_mut()
.unwrap()
.result
.as_mut()
.unwrap()
.is_error = true;
assert_eq!(file_path(&result, cwd), None);
}
}