fix(project-tool): address review findings on the apply foundation
Remediation of the single-app reconcile foundation after two independent review passes. No new feature surface — closes correctness, parity, and safety gaps in pull/plan/apply/prune. apply engine (manager-core): - validate_bundle reached parity with the interactive trigger API: reject an empty kv/docs/files collection_glob and a malformed pubsub topic_pattern (previously written and silently never matched), and lift the queue visibility floor to MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30) — apply had accepted a [5,29] value the dashboard refuses. Per-kind checks extracted into a pure, unit-tested validate_trigger_shape. - An omitted script `description` now means "leave as-is" (matching the other optional fields and the function's own documented contract) instead of clearing the stored value. - Doc fixes: drop stale "next milestone" notes; record the one deliberate plan/apply divergence (set-but-empty secret); document delete_route_tx as intentionally idempotent for reconcile. CLI: - Gate destructive `apply --prune` behind confirmation: interactive y/N, or `--yes` for CI; a non-interactive prune without `--yes` refuses rather than silently deleting. Journey tests pass `--yes`; a new test proves the gate refuses and deletes nothing. - Harden pull's filename safety: reject all control characters and the Unicode bidi-override / zero-width chars used for terminal/filename spoofing, and cap length at 200 bytes (NAME_MAX headroom). Tested: manager-core lib (incl. queue-floor + sparse-description parity) + CLI bins + 9 project-tool journeys green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,8 +62,9 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (contains a path \
|
||||
separator, `..`, or a leading dot); cannot pull",
|
||||
"script name {:?} is not filesystem-safe (a path separator, \
|
||||
`..`, a leading dot, a control character, or longer than 200 \
|
||||
bytes); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
@@ -207,14 +208,35 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
|
||||
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
|
||||
/// and any deceptive display character — a server-returned name is otherwise
|
||||
/// written verbatim as a filename and printed to the operator's terminal.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
|
||||
// `.rhai` suffix.
|
||||
const MAX_LEN: usize = 200;
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_LEN
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.contains('\0')
|
||||
&& !name.chars().any(is_deceptive_char)
|
||||
}
|
||||
|
||||
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
|
||||
/// bidirectional-override and zero-width/format characters used to spoof how a
|
||||
/// name renders in a terminal — both classes are unsafe to print verbatim.
|
||||
fn is_deceptive_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(c,
|
||||
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
|
||||
| '\u{2028}'..='\u{2029}' // line / paragraph separators
|
||||
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
|
||||
| '\u{2060}' // word joiner
|
||||
| '\u{2066}'..='\u{2069}' // bidi isolates
|
||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
@@ -276,6 +298,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars_and_overlong() {
|
||||
for bad in [
|
||||
"a\nb",
|
||||
"a\tb",
|
||||
"line\rdrop",
|
||||
"esc\x1b[2Jseq",
|
||||
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
|
||||
"zero\u{200B}width", // zero-width space
|
||||
"bom\u{FEFF}name", // BOM
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
assert!(
|
||||
!is_safe_filename(&"a".repeat(201)),
|
||||
"expected an over-long name to be rejected"
|
||||
);
|
||||
assert!(
|
||||
is_safe_filename(&"a".repeat(200)),
|
||||
"a 200-char name is at the limit and allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
|
||||
Reference in New Issue
Block a user