Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ jobs:
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo build --workspace
- run: cargo test --workspace --lib --no-fail-fast
# Exercise the released grep command against real files and exported
# synonym graphs, including Linux-only hostile filesystem names.
- run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree
# GitHub-side home for the updater integration contracts (managed-mode
# receipt/refusal, strict v2 manifest readers, R2 checksum fail-closed).
# On Gitea these lanes live in .gitea/workflows/native-ci.yml, which is
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ members = [
]

[workspace.package]
version = "1.21.18"
version = "1.21.19"
edition = "2024"
authors = ["Terraphim Team <team@terraphim.ai>"]
documentation = "https://terraphim.ai"
Expand Down
165 changes: 165 additions & 0 deletions crates/terraphim_agent/tests/ci_guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,168 @@ fn publish_gate_tests_pass() {
String::from_utf8_lossy(&out.stderr),
);
}

/// Collect every `.rs` file under `crates/`, skipping build directories.
///
/// Uses `std::fs` rather than shelling out to `fd`/`rg`: the Gitea runner's
/// program allowlist rejects anything that is not `cargo` or a repo script, and
/// a guard that silently cannot run is worse than no guard.
fn workspace_rust_sources() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
// Build output and vendored sources are not this repo's code.
if name.starts_with("target") || name == ".git" {
continue;
}
walk(&path, out);
} else if file_type.is_file() && path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}

let mut out = Vec::new();
walk(&workspace_root().join("crates"), &mut out);
// This guard file quotes the very literals it forbids, so exclude it.
let own_name = Path::new(file!())
.file_name()
.expect("guard file name")
.to_owned();
out.retain(|p| p.file_name() != Some(own_name.as_os_str()));
out.sort();
out
}

/// Every `FilePicker::new` call must refuse fff-search's mmap content cache.
///
/// fff-search's only `memmap2::Mmap::map` call sits in `get_cached_content`, and
/// its safety contract requires that the mapped file is not modified
/// concurrently. The crate delegates that guarantee to a background watcher
/// which `FilePicker::new` never installs, so a concurrent writer produces
/// invalid-UTF-8 match text and an in-place truncation raises SIGBUS (EXP-302
/// killed `terraphim_mcp_server --sse` with exit 135 this way). The remedy is a
/// budget whose `max_files` is 0, which makes `get_cached_content` bail at its
/// count guard before it maps anything.
///
/// Leaving `cache_budget` at `None` -- or omitting it, which `Default` turns
/// into `None` -- asks fff-search to size a budget from the repo and reopens the
/// finding. Hence: no `cache_budget: None` anywhere, and every picker must name
/// `no_mmap_budget()` and `follow_symlinks: false` in its options block.
///
/// See UF-039 / #293.
#[test]
fn every_file_picker_refuses_the_mmap_cache() {
// Built by concatenation so this test's own source does not contain the
// literal it forbids (the walk excludes this file too, belt and braces).
let forbidden = concat!("cache_budget: ", "None");

for path in workspace_rust_sources() {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let display = path.display();

for (i, line) in text.lines().enumerate() {
assert!(
!line.contains(forbidden),
"{display}:{} relies on fff-search sizing its own mmap cache budget; \
pass `Some(no_mmap_budget())` instead (UF-039 / #293)",
i + 1,
);
}

let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
if !line.contains("FilePicker::new(") {
continue;
}
// The options block runs from the call to the struct-update tail.
let end = lines[i..]
.iter()
.position(|l| l.contains("..FilePickerOptions::default()"))
.map(|off| i + off)
.unwrap_or_else(|| {
panic!(
"{display}:{} opens a FilePicker without a \
`..FilePickerOptions::default()` tail; this guard cannot \
establish what its options are",
i + 1
)
});
let block = lines[i..=end].join("\n");

assert!(
block.contains("no_mmap_budget()"),
"{display}:{} opens a FilePicker without `no_mmap_budget()`; \
fff-search would then mmap file content with no invalidation \
(UF-039 / #293)",
i + 1,
);
// fff-search stores relative paths lossily and then truncates them
// into a fixed 4096-byte buffer behind `from_utf8_unchecked`, so a
// non-UTF-8 or over-long entry name yields an invalid `&str` and can
// panic in `DirItem::dir_name`. The base path is attacker-chosen, so
// the tree must be refused before the picker sees it. UF-040 / #296.
let preceding = lines[i.saturating_sub(40)..i].join("\n");
assert!(
preceding.contains("reject_hostile_tree("),
"{display}:{} opens a FilePicker without a `reject_hostile_tree(` \
call in the preceding 40 lines; a hostile entry name would then \
reach fff-search's `from_utf8_unchecked` (UF-040 / #296)",
i + 1,
);

assert!(
block.contains("follow_symlinks: false"),
"{display}:{} opens a FilePicker without an explicit \
`follow_symlinks: false`; the guard reasoning assumes symlinks \
are not followed and must not rest on an upstream default \
(Refs #293)",
i + 1,
);
}
}
}

/// Both crates that depend on fff-search must pin it exactly.
///
/// fff-search 0.10.6 adds a second, unbudgeted `memmap2::Mmap::map` in
/// `get_content_for_search`, reached exactly when the zero-file cache budget
/// from #293 makes `get_cached_content` return `None` -- so a minor bump would
/// reintroduce UF-039 for every file at or above its 256 KiB threshold while
/// leaving the mitigation visibly in place. `^0.8.4` already confines cargo to
/// 0.8.x and no stable 0.8.x above 0.8.4 exists, so this pin is not urgent; it
/// guards against a deliberate bump and keeps the decision from lapsing
/// silently. See Delta 5 of the audit; Refs #293, #257.
#[test]
fn fff_search_is_pinned_exactly() {
let root = workspace_root();
for crate_name in ["terraphim_grep", "terraphim_mcp_server"] {
let manifest = root.join("crates").join(crate_name).join("Cargo.toml");
let text = std::fs::read_to_string(&manifest)
.unwrap_or_else(|e| panic!("read {}: {e}", manifest.display()));

let decl = text
.lines()
.find(|l| l.trim_start().starts_with("fff-search = "))
.unwrap_or_else(|| panic!("{} declares no fff-search dependency", manifest.display()));

assert!(
decl.contains(r#"version = "=0.8.4""#),
"{} must pin fff-search exactly (`version = \"=0.8.4\"`); a caret \
requirement lets a minor bump reintroduce UF-039 (Refs #293, #257). \
Found: {decl}",
manifest.display(),
);
}
}
17 changes: 17 additions & 0 deletions crates/terraphim_grep/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to terraphim_grep are documented here.

## [1.21.19] - 2026-10-11

### Fixed
- Search an exact editor-exported synonym across its complete knowledge-graph
equivalence group. Display limits no longer omit aliases or synthesis evidence.
- Treat graph alternatives as literal text, including slash and filter-looking
words, while preserving filters in compound user queries.
- Honor requested `--answer` synthesis when retrieved evidence is sufficient and
preserve the source's decision/implementation status in the answer.
- Pin fff-search and reject mmap content-cache configuration and unsupported
filesystem names before indexing. Concurrent-writer hazards remain outside
this partial mitigation.

### Changed
- Batch synonym searches into one scan per root and document the editor graph
export → search-only → optional synthesis workflow.

## [Unreleased]

### Fixed
Expand Down
23 changes: 22 additions & 1 deletion crates/terraphim_grep/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,28 @@ terraphim_automata = { version = "1.21.0", registry = "terraphim" }
terraphim_service = { version = "1.21.1", optional = true, registry = "terraphim" }
terraphim_config = { version = "1.15.0" }

fff-search = { version = "0.8.4", optional = true }
# Pinned exactly, not `^0.8.4` (Refs #293, #257).
#
# fff-search 0.10.6 is the first release requiring a git2 that patches
# RUSTSEC-2026-0183/0184, but it also adds a SECOND, unbudgeted
# `memmap2::Mmap::map` at `types.rs:762` in `get_content_for_search`, gated only
# by a 256 KiB threshold on Linux and reached exactly when a zero-file cache
# budget makes `get_cached_content` return `None`. Upgrading would therefore
# reintroduce UF-039 for every file at or above that size while leaving the
# mitigation visibly in place.
#
# Staying on 0.8.4 is safe because both advisories are API-specific and neither
# vulnerable API is reachable: fff-search 0.8.4 references only
# `git2::Repository`, `git2::Error` and the `git2::Status` flags, with no
# `Remote`, `Blame` or `blame_buffer` anywhere, and no workspace crate depends
# on git2 directly.
#
# Urgency, stated accurately: `^0.8.4` already confines cargo to 0.8.x and
# crates.io has no stable 0.8.x above 0.8.4 (only `0.8.5-nightly.*`
# pre-releases, which cargo will not select), so a routine `cargo update` cannot
# reach 0.10.6 today. The pin guards against a deliberate minor bump, and it is
# what stops the reachability argument above lapsing silently.
fff-search = { version = "=0.8.4", optional = true }

async-trait.workspace = true
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
Expand Down
Loading
Loading