Skip to content

Commit 74bcb20

Browse files
committed
fix resync carrier validation and live watches
1 parent 6778ba2 commit 74bcb20

4 files changed

Lines changed: 266 additions & 71 deletions

File tree

crates/agent-spec/src/spec.rs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -771,35 +771,63 @@ fn validate_resource_uri(uri: &str) -> Result<(), &'static str> {
771771
if uri.is_empty() {
772772
return Err("catalog-relative uri must be a non-empty relative path");
773773
}
774-
let decoded = percent_decode_path(uri)?;
775-
if decoded.starts_with(b"/") || decoded.split(|byte| *byte == b'/').any(|part| part == b"..") {
774+
let decoded = decode_percent_path(uri)?;
775+
if decoded.starts_with('/') || decoded.split('/').any(|part| part == "..") {
776776
return Err(
777777
"catalog-relative uri must be a relative path without parent (`..`) components",
778778
);
779779
}
780780
Ok(())
781781
}
782782

783-
fn percent_decode_path(uri: &str) -> Result<Vec<u8>, &'static str> {
784-
let bytes = uri.as_bytes();
783+
/// Decode filesystem-path percent escapes without allowing an escape to change path segmentation.
784+
///
785+
/// Consumers may apply additional policy to the decoded path (for example, catalog-relative
786+
/// resources reject every parent component). This shared boundary rejects bytes that cannot name
787+
/// the same UTF-8 filesystem path the resource URI visibly denotes.
788+
pub fn decode_percent_path(encoded_path: &str) -> Result<String, &'static str> {
789+
let bytes = encoded_path.as_bytes();
785790
let mut decoded = Vec::with_capacity(bytes.len());
791+
let mut component_start = 0;
792+
let mut component_had_escape = false;
786793
let mut offset = 0;
787794
while offset < bytes.len() {
795+
if bytes[offset] == b'/' {
796+
if component_had_escape && decoded[component_start..] == *b".." {
797+
return Err("path contains an encoded parent (`..`) component");
798+
}
799+
decoded.push(b'/');
800+
component_start = decoded.len();
801+
component_had_escape = false;
802+
offset += 1;
803+
continue;
804+
}
788805
if bytes[offset] != b'%' {
789806
decoded.push(bytes[offset]);
790807
offset += 1;
791808
continue;
792809
}
793810
let Some(high) = bytes.get(offset + 1).and_then(|byte| hex_digit(*byte)) else {
794-
return Err("catalog-relative uri contains an invalid percent escape");
811+
return Err("path contains an invalid percent escape");
795812
};
796813
let Some(low) = bytes.get(offset + 2).and_then(|byte| hex_digit(*byte)) else {
797-
return Err("catalog-relative uri contains an invalid percent escape");
814+
return Err("path contains an invalid percent escape");
798815
};
799-
decoded.push((high << 4) | low);
816+
let byte = (high << 4) | low;
817+
if matches!(byte, b'/' | b'\\') {
818+
return Err("path contains an encoded separator");
819+
}
820+
if byte == b'\0' {
821+
return Err("path decodes to NUL");
822+
}
823+
decoded.push(byte);
824+
component_had_escape = true;
800825
offset += 3;
801826
}
802-
Ok(decoded)
827+
if component_had_escape && decoded[component_start..] == *b".." {
828+
return Err("path contains an encoded parent (`..`) component");
829+
}
830+
String::from_utf8(decoded).map_err(|_| "percent-decoded path is not valid UTF-8")
803831
}
804832

805833
fn hex_digit(byte: u8) -> Option<u8> {
@@ -1407,12 +1435,13 @@ mod tests {
14071435
}
14081436

14091437
#[test]
1410-
fn catalog_relative_uris_reject_only_parent_components_after_percent_decoding() {
1438+
fn catalog_relative_uris_share_filesystem_safe_percent_decoding() {
14111439
for uri in [
14121440
"report..md",
14131441
"reports/.../goal.md",
14141442
"reports/%2Ereport.md",
14151443
"reports/child..name/goal.md",
1444+
"reports/with%20space/%E2%82%AC.md",
14161445
] {
14171446
assert_eq!(validate_resource_uri(uri), Ok(()), "{uri}");
14181447
}
@@ -1424,6 +1453,11 @@ mod tests {
14241453
"%2e%2e/goal.md",
14251454
"reports/%2E%2e/goal.md",
14261455
"reports%2f..%2fgoal.md",
1456+
"reports/encoded%2Fseparator",
1457+
"reports/encoded%5cseparator",
1458+
"reports/a%00b",
1459+
"reports/%FF.md",
1460+
"reports/bad%escape",
14271461
] {
14281462
assert!(validate_resource_uri(uri).is_err(), "{uri}");
14291463
}

crates/agent-spec/tests/discovery.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,108 @@ fn catalog_relative_resource_uris_are_an_st2_extension_resolved_against_the_decl
12561256
assert!(absolute.is_err(), "absolute paths must keep a scheme");
12571257
}
12581258

1259+
#[test]
1260+
fn resource_percent_paths_are_validated_consistently_across_public_inputs() {
1261+
let tmp = tempfile::tempdir().unwrap();
1262+
write(
1263+
tmp.path(),
1264+
"agents/h/valid-kdl/agent.kdl",
1265+
r#"agent "valid-kdl" {
1266+
host "h"
1267+
command "true"
1268+
resource "work" uri="resources/with%20space.md" reason="Task."
1269+
}"#,
1270+
);
1271+
write(
1272+
tmp.path(),
1273+
"agents/h/invalid-kdl/agent.kdl",
1274+
r#"agent "invalid-kdl" {
1275+
host "h"
1276+
command "true"
1277+
resource "work" uri="resources/a%00b" reason="Task."
1278+
}"#,
1279+
);
1280+
write(
1281+
tmp.path(),
1282+
"agents/h/valid-json/agent.json",
1283+
r#"{
1284+
"identity": "valid-json",
1285+
"host": "h",
1286+
"command": "true",
1287+
"resource": {"work": {"uri": "resources/with%20space.md", "reason": "Task."}}
1288+
}"#,
1289+
);
1290+
write(
1291+
tmp.path(),
1292+
"agents/h/invalid-json/agent.json",
1293+
r#"{
1294+
"identity": "invalid-json",
1295+
"host": "h",
1296+
"command": "true",
1297+
"resource": {"work": {"uri": "resources/encoded%2Fseparator", "reason": "Task."}}
1298+
}"#,
1299+
);
1300+
write(
1301+
tmp.path(),
1302+
"agents/h/valid-toml/agent.toml",
1303+
r#"identity = "valid-toml"
1304+
host = "h"
1305+
command = "true"
1306+
[resource.work]
1307+
uri = "resources/with%20space.md"
1308+
reason = "Task."
1309+
"#,
1310+
);
1311+
write(
1312+
tmp.path(),
1313+
"agents/h/invalid-toml/agent.toml",
1314+
r#"identity = "invalid-toml"
1315+
host = "h"
1316+
command = "true"
1317+
[resource.work]
1318+
uri = "resources/%FF.md"
1319+
reason = "Task."
1320+
"#,
1321+
);
1322+
1323+
let found = discover(tmp.path());
1324+
let identities = found
1325+
.specs
1326+
.iter()
1327+
.map(|spec| spec.identity.as_str())
1328+
.collect::<Vec<_>>();
1329+
assert_eq!(
1330+
identities,
1331+
vec!["valid-json", "valid-kdl", "valid-toml"],
1332+
"{:?}",
1333+
found.errors
1334+
);
1335+
assert_eq!(found.errors.len(), 3, "{:?}", found.errors);
1336+
1337+
let valid: Resource = serde_json::from_str(
1338+
r#"{"name":"work","uri":"resources/with%20space/%E2%82%AC.md","reason":"Task."}"#,
1339+
)
1340+
.unwrap();
1341+
assert_eq!(
1342+
valid.uri(),
1343+
"resources/with%20space/%E2%82%AC.md",
1344+
"validation must preserve the URI identity instead of normalizing it"
1345+
);
1346+
for uri in [
1347+
"resources/a%00b",
1348+
"resources/encoded%2Fseparator",
1349+
"resources/encoded%5cseparator",
1350+
"resources/%FF.md",
1351+
"resources/%2e%2e/goal.md",
1352+
] {
1353+
let descriptor = format!(r#"{{"name":"work","uri":"{uri}","reason":"Task."}}"#);
1354+
assert!(
1355+
serde_json::from_str::<Resource>(&descriptor).is_err(),
1356+
"{uri}"
1357+
);
1358+
}
1359+
}
1360+
12591361
#[test]
12601362
fn duplicate_json_resource_names_are_rejected_instead_of_last_write_winning() {
12611363
let tmp = tempfile::tempdir().unwrap();

src/resync.rs

Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
1818
use notify::Watcher as _;
1919
use sha2::{Digest as _, Sha256};
2020

21-
use agent_spec::spec::AgentSpec;
21+
use agent_spec::spec::{AgentSpec, decode_percent_path};
2222

2323
/// The reserved stream used only by the supervisor's crate-internal resync publisher.
2424
pub const RESYNC_STREAM: &str = "resync";
@@ -127,62 +127,13 @@ fn resolve_local_path(agent_dir: &Path, uri: &str) -> Option<PathBuf> {
127127
{
128128
return None;
129129
}
130-
let path = decode_percent_path(encoded_path)?;
130+
let path = PathBuf::from(decode_percent_path(encoded_path).ok()?);
131131
return path.is_absolute().then(|| lexical_clean(&path));
132132
}
133-
let path = decode_percent_path(uri)?;
133+
let path = PathBuf::from(decode_percent_path(uri).ok()?);
134134
Some(lexical_clean(&agent_dir.join(path)))
135135
}
136136

137-
fn decode_percent_path(encoded_path: &str) -> Option<PathBuf> {
138-
let bytes = encoded_path.as_bytes();
139-
let mut decoded = Vec::with_capacity(bytes.len());
140-
let mut component_start = 0;
141-
let mut component_had_escape = false;
142-
let mut index = 0;
143-
while index < bytes.len() {
144-
if bytes[index] == b'/' {
145-
if component_had_escape && decoded[component_start..] == *b".." {
146-
return None;
147-
}
148-
decoded.push(b'/');
149-
component_start = decoded.len();
150-
component_had_escape = false;
151-
index += 1;
152-
continue;
153-
}
154-
if bytes[index] != b'%' {
155-
decoded.push(bytes[index]);
156-
index += 1;
157-
continue;
158-
}
159-
let high = hex_value(*bytes.get(index + 1)?)?;
160-
let low = hex_value(*bytes.get(index + 2)?)?;
161-
let byte = (high << 4) | low;
162-
// A decoded separator changes URI path segmentation, and NUL cannot be a filesystem path
163-
// byte. Reject both rather than silently resolving a different carrier.
164-
if matches!(byte, b'/' | b'\\' | b'\0') {
165-
return None;
166-
}
167-
decoded.push(byte);
168-
component_had_escape = true;
169-
index += 3;
170-
}
171-
if component_had_escape && decoded[component_start..] == *b".." {
172-
return None;
173-
}
174-
Some(PathBuf::from(String::from_utf8(decoded).ok()?))
175-
}
176-
177-
fn hex_value(byte: u8) -> Option<u8> {
178-
match byte {
179-
b'0'..=b'9' => Some(byte - b'0'),
180-
b'a'..=b'f' => Some(byte - b'a' + 10),
181-
b'A'..=b'F' => Some(byte - b'A' + 10),
182-
_ => None,
183-
}
184-
}
185-
186137
/// Remove `.` and `..` components lexically. This deliberately does not inspect the filesystem:
187138
/// classification follows the authored path structure without resolving symlinks.
188139
fn lexical_clean(path: &Path) -> PathBuf {
@@ -2195,10 +2146,14 @@ mod tests {
21952146
"file:///tmp/encoded%5Cseparator",
21962147
"file:///tmp/bad%escape",
21972148
"file:///tmp/%2E%2E/escape",
2149+
"file:///tmp/a%00b",
2150+
"file:///tmp/%FF.md",
21982151
"resources/encoded%2Fseparator",
21992152
"resources/encoded%5Cseparator",
22002153
"resources/%2E%2E/outside.md",
22012154
"resources/bad%escape",
2155+
"resources/a%00b",
2156+
"resources/%FF.md",
22022157
"http://x/y",
22032158
"worktree://repo/main",
22042159
"GitHub-Issue://org/repo/41",

0 commit comments

Comments
 (0)