Skip to content

Commit d9e7283

Browse files
committed
解析 Cargo 显式工作区文档规则引用并核验路径边界
1 parent 6bd3b71 commit d9e7283

19 files changed

Lines changed: 3261 additions & 28 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,4 +1103,4 @@ Native Clippy documentation findings now receive specific Errors/Panics/Safety r
11031103

11041104
Cargo documentation discovery now records manifest levels for five exact native lints per build root, bound to the observed manifest digest. Inheritance, groups, source attributes and absent declarations remain unresolved. Initialization stores details in the profile and retains AGENTS summaries/references; it does not add rules or qualify detailed documentation. See [declaration acceptance](tests/acceptance/cargo-documentation-declarations.md).
11051105

1106-
Cargo documentation discovery now associates an opted-in member with its nearest observed workspace lint declarations, retaining both manifest identities and the original member configuration reference. Unreadable or changed candidates are incomplete; nearer invalid/missing rules are not replaced with farther rules. Explicit workspace references and full membership/effective coverage remain unresolved. This candidate association grants no production qualification.
1106+
Cargo documentation discovery now associates an opted-in member with its nearest observed workspace lint declarations, retaining both manifest identities and the original member configuration reference. Unreadable or changed candidates are incomplete; nearer invalid/missing rules are not replaced with farther rules. Project-local portable relative package.workspace references now select only their declared source, with bounded traversal and digest checks; absolute/nonportable references and full membership/effective coverage remain unresolved. This candidate association grants no production qualification.

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1080,4 +1080,4 @@ Rust 的详细文档任务现对原生 Clippy `missing_errors_doc`、`missing_pa
10801080

10811081
Cargo 文档配置现在逐构建根记录五项精确 lint 的清单声明等级,绑定同次摘要;继承、组、源码属性和未声明均保留待核验。`init` 将详情写入项目画像,AGENTS 保留摘要与引用,不自行添加规则或授予详细文档合格。见[声明验收](tests/acceptance/cargo-documentation-declarations.md)。
10821082

1083-
Cargo文档配置发现现可将明确选择继承的成员与最近已观察workspace规则关联,保留两份清单身份及原成员配置引用;候选不可读或变化保持发现不完整,最近规则缺失/非法不能借用更远规则。显式workspace引用、完整成员归属与生效覆盖仍待核验,这项候选关联不授予生产资格。
1083+
Cargo文档配置发现现可将明确选择继承的成员与最近已观察workspace规则关联,保留两份清单身份及原成员配置引用;候选不可读或变化保持发现不完整,最近规则缺失/非法不能借用更远规则。项目内便携相对package.workspace引用现只选择声明来源并核验有界遍历及摘要;绝对/非便携引用、完整成员归属与生效覆盖仍待核验,这项候选关联不授予生产资格。

‎crates/codeguard-adapters/src/cargo_documentation_workspace.rs‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
//! 工作区文档规则的候选来源关联;不计算 Cargo 成员归属或有效 lint 覆盖。
2-
use crate::{CheckerConfiguration, inspect_cargo_documentation_config};
2+
use crate::{CargoWorkspaceReference, CheckerConfiguration, inspect_cargo_documentation_config};
33

44
/// 关联显式继承成员与一份已绑定摘要的工作区候选清单。
55
/// 参数是成员/候选清单字节、成员构建根和两份来源;无 workspace 返回 None 以继续祖先搜索。
6-
/// 返回配置始终 unknown;显式 package.workspace、非法数据或缺规则均不能推断生效。
6+
/// 返回配置始终 unknown;显式引用必须匹配目标,非法数据或缺规则均不能推断生效。
77
pub fn inspect_cargo_documentation_workspace(
88
member: &[u8],
99
candidate: &[u8],
@@ -19,15 +19,20 @@ pub fn inspect_cargo_documentation_workspace(
1919
return Some(rows);
2020
}
2121
let parsed_member = parse(member)?;
22-
if parsed_member
23-
.get("package")
24-
.and_then(|p| p.get("workspace"))
25-
.is_some()
26-
{
27-
for row in &mut rows {
28-
row.reason = "cargo_doc_lints_explicit_workspace_reference_unresolved".into();
22+
match CargoWorkspaceReference::observe(member, source) {
23+
Ok(Some(reference)) if reference.manifest != workspace_source => {
24+
for row in &mut rows {
25+
row.reason = "cargo_doc_lints_explicit_workspace_reference_unresolved".into();
26+
}
27+
return Some(rows);
2928
}
30-
return Some(rows);
29+
Err(_) => {
30+
for row in &mut rows {
31+
row.reason = "cargo_doc_lints_explicit_workspace_reference_invalid".into();
32+
}
33+
return Some(rows);
34+
}
35+
_ => {}
3136
}
3237
let Some(parsed) = parse(candidate) else {
3338
for row in &mut rows {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Cargo 显式工作区引用的有界路径计划;不访问文件系统或计算成员归属。
2+
3+
/// 一份显式工作区目录引用的项目内候选路径及必须逐项核验的目录。
4+
#[derive(Debug, Eq, PartialEq)]
5+
pub struct CargoWorkspaceReference {
6+
/// 项目根相对的候选清单;仅在全部遍历目录和当前摘要核验后可读取。
7+
pub manifest: String,
8+
/// 按遍历顺序保留目录,包括被父目录组件消除的路径;不能跳过缺失或链接。
9+
pub traversal_directories: Vec<String>,
10+
}
11+
12+
impl CargoWorkspaceReference {
13+
/// 观察至多256KiB的成员清单和相对来源,返回显式引用路径计划或未声明。
14+
/// 非UTF8/TOML、根与成员双角色、非法/绝对/非便携路径、越界和64组件预算返回未解析原因。
15+
pub fn observe(member: &[u8], source: &str) -> Result<Option<Self>, &'static str> {
16+
if member.len() > 256 * 1024 {
17+
return Err("cargo_workspace_reference_manifest_invalid");
18+
}
19+
let parsed: toml::Value = std::str::from_utf8(member)
20+
.ok()
21+
.and_then(|s| s.parse().ok())
22+
.ok_or("cargo_workspace_reference_manifest_invalid")?;
23+
let Some(reference) = parsed.get("package").and_then(|p| p.get("workspace")) else {
24+
return Ok(None);
25+
};
26+
if parsed.get("workspace").is_some() {
27+
return Err("cargo_workspace_reference_conflicting_root");
28+
}
29+
let reference = reference
30+
.as_str()
31+
.filter(|s| portable_relative(s))
32+
.ok_or("cargo_workspace_reference_invalid_path")?;
33+
if !portable_relative(source) || source.rsplit('/').next() != Some("Cargo.toml") {
34+
return Err("cargo_workspace_reference_invalid_source");
35+
}
36+
let mut segments: Vec<&str> = source.split('/').collect();
37+
segments.pop();
38+
if segments.iter().any(|s| matches!(*s, "" | "." | "..")) {
39+
return Err("cargo_workspace_reference_invalid_source");
40+
}
41+
let reference_segments: Vec<&str> = reference.split('/').collect();
42+
if segments.len() + reference_segments.len() > 64 {
43+
return Err("cargo_workspace_reference_budget_exceeded");
44+
}
45+
let mut directories = vec![".".into()];
46+
for end in 1..=segments.len() {
47+
directories.push(segments[..end].join("/"));
48+
}
49+
for part in reference_segments {
50+
match part {
51+
"" | "." => {}
52+
".." => {
53+
if segments.pop().is_none() {
54+
return Err("cargo_workspace_reference_outside_project");
55+
}
56+
}
57+
part => segments.push(part),
58+
}
59+
directories.push(if segments.is_empty() {
60+
".".into()
61+
} else {
62+
segments.join("/")
63+
});
64+
}
65+
let prefix = if segments.is_empty() {
66+
String::new()
67+
} else {
68+
format!("{}/", segments.join("/"))
69+
};
70+
Ok(Some(Self {
71+
manifest: format!("{prefix}Cargo.toml"),
72+
traversal_directories: directories,
73+
}))
74+
}
75+
}
76+
77+
fn portable_relative(path: &str) -> bool {
78+
!path.is_empty()
79+
&& path.len() <= 4096
80+
&& !path.starts_with('/')
81+
&& !path.contains(['\\', ':'])
82+
&& !path.chars().any(char::is_control)
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::CargoWorkspaceReference;
88+
#[test]
89+
fn explicit_reference_preserves_traversal_and_rejects_escape_or_bad_types() {
90+
let manifest = |value: &str| format!("[package]\nname='a'\nworkspace={value}\n");
91+
let bytes = manifest("'./missing/../shared/'");
92+
let r = CargoWorkspaceReference::observe(bytes.as_bytes(), "member/Cargo.toml")
93+
.unwrap()
94+
.unwrap();
95+
assert_eq!(r.manifest, "member/shared/Cargo.toml");
96+
assert!(
97+
r.traversal_directories
98+
.iter()
99+
.any(|d| d == "member/missing")
100+
);
101+
assert_eq!(
102+
CargoWorkspaceReference::observe(
103+
manifest("'../shared'").as_bytes(),
104+
"member/Cargo.toml"
105+
)
106+
.unwrap()
107+
.unwrap()
108+
.manifest,
109+
"shared/Cargo.toml"
110+
);
111+
for raw in [
112+
"'../../outside'",
113+
"'/absolute'",
114+
"'C:/absolute'",
115+
"true",
116+
"[]",
117+
"''",
118+
"'a\\b'",
119+
] {
120+
assert!(
121+
CargoWorkspaceReference::observe(manifest(raw).as_bytes(), "member/Cargo.toml")
122+
.is_err(),
123+
"{raw}"
124+
);
125+
}
126+
assert!(
127+
CargoWorkspaceReference::observe(
128+
b"[package]\nworkspace='.'\n[workspace]\n",
129+
"Cargo.toml"
130+
)
131+
.is_err()
132+
);
133+
assert!(
134+
CargoWorkspaceReference::observe(manifest("'.'").as_bytes(), "../Cargo.toml").is_err()
135+
);
136+
assert!(
137+
CargoWorkspaceReference::observe(
138+
manifest(&format!("'{}'", "a/".repeat(65))).as_bytes(),
139+
"member/Cargo.toml"
140+
)
141+
.is_err()
142+
);
143+
assert!(
144+
CargoWorkspaceReference::observe(b"[package]\nname='a'\n", "Cargo.toml")
145+
.unwrap()
146+
.is_none()
147+
);
148+
}
149+
}

‎crates/codeguard-adapters/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub use cargo_build_parsed::CargoBuildParsed;
2727
mod cargo_clippy;
2828
mod cargo_documentation_config;
2929
mod cargo_documentation_workspace;
30+
mod cargo_workspace_reference;
3031
mod cargo_module_model;
3132
mod cargo_edition_declaration;
3233
mod cargo_rustdoc;
@@ -128,6 +129,7 @@ pub use capability_validation::validate_capability_inventory;
128129
pub use cargo_clippy::{ClippyFinding, ClippyParsed, parse_cargo_clippy_json};
129130
pub use cargo_documentation_config::inspect_cargo_documentation_config;
130131
pub use cargo_documentation_workspace::inspect_cargo_documentation_workspace;
132+
pub use cargo_workspace_reference::CargoWorkspaceReference;
131133
pub use cargo_module_model::CargoModuleModel;
132134
pub use cargo_edition_declaration::CargoEditionDeclaration;
133135
pub use cargo_rustdoc::parse_cargo_rustdoc_json;

‎crates/codeguard-cli/src/discovery.rs‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,59 @@ fn inspect_cargo_documentation_context<P: ObservationPort>(
381381
let Some(member) = bytes else {
382382
return (original, None);
383383
};
384+
match codeguard_adapters::CargoWorkspaceReference::observe(member, manifest) {
385+
Err(reason) => {
386+
let mut rows = original;
387+
for row in &mut rows {
388+
row.reason = "cargo_doc_lints_explicit_workspace_reference_invalid".into();
389+
row.next_action = format!(
390+
"显式workspace引用未解析:{reason};核验原成员清单,不回退祖先规则、不修改无关源码"
391+
);
392+
}
393+
return (rows, None);
394+
}
395+
Ok(Some(reference)) => {
396+
for directory in &reference.traversal_directories {
397+
if !matches!(
398+
observation.classify(&root.join(directory)),
399+
Ok(ObservedPathKind::Directory)
400+
) {
401+
return (original, Some(directory.clone()));
402+
}
403+
}
404+
let name = &reference.manifest;
405+
if !hashes.contains_key(name)
406+
|| !matches!(
407+
observation.classify(&root.join(name)),
408+
Ok(ObservedPathKind::File)
409+
)
410+
{
411+
return (original, Some(name.clone()));
412+
}
413+
let Ok(candidate) = observation.read_bounded(&root.join(name), MAX_MANIFEST_BYTES)
414+
else {
415+
return (original, Some(name.clone()));
416+
};
417+
if hashes.get(name) != Some(&format!("{:x}", Sha256::digest(&candidate))) {
418+
return (original, Some(name.clone()));
419+
}
420+
let rows = codeguard_adapters::inspect_cargo_documentation_workspace(
421+
member, &candidate, build_root, manifest, name,
422+
)
423+
.unwrap_or_else(|| {
424+
let mut rows = original;
425+
for row in &mut rows {
426+
row.reason = "cargo_doc_lints_explicit_workspace_not_defined".into();
427+
row.next_action = format!(
428+
"显式候选清单{name}未声明workspace;核验原package.workspace,不回退祖先规则"
429+
);
430+
}
431+
rows
432+
});
433+
return (rows, None);
434+
}
435+
Ok(None) => {}
436+
}
384437
let mut parent = Path::new(manifest).parent();
385438
for _ in 0..64 {
386439
let Some(directory) = parent else {
@@ -878,6 +931,111 @@ mod tests {
878931
assert!(!rows[1].next_action.contains("missing_errors_doc=warn"));
879932
}
880933

934+
struct ExplicitWorkspaceObservation {
935+
directory_kind: ObservedPathKind,
936+
file_kind: ObservedPathKind,
937+
bytes: Result<Vec<u8>, io::ErrorKind>,
938+
}
939+
impl ObservationPort for ExplicitWorkspaceObservation {
940+
fn classify(&self, path: &Path) -> io::Result<ObservedPathKind> {
941+
match path.to_str() {
942+
Some("project/." | "project/external") => Ok(ObservedPathKind::Directory),
943+
Some("project/shared") => Ok(self.directory_kind),
944+
Some("project/shared/Cargo.toml") => Ok(self.file_kind),
945+
_ => Err(io::Error::from(io::ErrorKind::NotFound)),
946+
}
947+
}
948+
fn children(&self, _path: &Path) -> io::Result<Vec<PathBuf>> {
949+
Ok(Vec::new())
950+
}
951+
fn read_bounded(&self, _path: &Path, _limit: u64) -> io::Result<Vec<u8>> {
952+
self.bytes.clone().map_err(io::Error::from)
953+
}
954+
}
955+
956+
#[test]
957+
fn explicit_cargo_workspace_identity_io_and_links_cannot_borrow_ancestor_rules() {
958+
let member = b"[package]\nname='external'\nversion='0.1.0'\nworkspace='../shared'\n[lints]\nworkspace=true\n";
959+
let workspace = b"[workspace]\nmembers=['../external']\n[workspace.lints.clippy]\nmissing_errors_doc='warn'\n";
960+
let mut hashes = BTreeMap::from([
961+
(
962+
"external/Cargo.toml".into(),
963+
format!("{:x}", Sha256::digest(member)),
964+
),
965+
(
966+
"shared/Cargo.toml".into(),
967+
format!("{:x}", Sha256::digest(workspace)),
968+
),
969+
]);
970+
for (directory_kind, file_kind, bytes, blocked) in [
971+
(
972+
ObservedPathKind::Directory,
973+
ObservedPathKind::File,
974+
Ok(workspace.to_vec()),
975+
None,
976+
),
977+
(
978+
ObservedPathKind::Directory,
979+
ObservedPathKind::File,
980+
Ok(b"[workspace]\n".to_vec()),
981+
Some("shared/Cargo.toml"),
982+
),
983+
(
984+
ObservedPathKind::Directory,
985+
ObservedPathKind::File,
986+
Err(io::ErrorKind::PermissionDenied),
987+
Some("shared/Cargo.toml"),
988+
),
989+
(
990+
ObservedPathKind::Symlink,
991+
ObservedPathKind::File,
992+
Ok(workspace.to_vec()),
993+
Some("shared"),
994+
),
995+
(
996+
ObservedPathKind::Directory,
997+
ObservedPathKind::Symlink,
998+
Ok(workspace.to_vec()),
999+
Some("shared/Cargo.toml"),
1000+
),
1001+
] {
1002+
let port = ExplicitWorkspaceObservation {
1003+
directory_kind,
1004+
file_kind,
1005+
bytes,
1006+
};
1007+
let (rows, actual) = super::inspect_cargo_documentation_context(
1008+
Some(member),
1009+
"external",
1010+
"external/Cargo.toml",
1011+
Path::new("project"),
1012+
&hashes,
1013+
&port,
1014+
);
1015+
assert_eq!(actual.as_deref(), blocked);
1016+
assert!(rows.iter().all(|r| r.configuration == "unknown"));
1017+
assert_eq!(
1018+
rows[1].next_action.contains("missing_errors_doc=warn"),
1019+
blocked.is_none()
1020+
);
1021+
}
1022+
hashes.remove("shared/Cargo.toml");
1023+
let port = ExplicitWorkspaceObservation {
1024+
directory_kind: ObservedPathKind::Directory,
1025+
file_kind: ObservedPathKind::File,
1026+
bytes: Ok(workspace.to_vec()),
1027+
};
1028+
let (_, blocked) = super::inspect_cargo_documentation_context(
1029+
Some(member),
1030+
"external",
1031+
"external/Cargo.toml",
1032+
Path::new("project"),
1033+
&hashes,
1034+
&port,
1035+
);
1036+
assert_eq!(blocked.as_deref(), Some("shared/Cargo.toml"));
1037+
}
1038+
8811039
struct ChangingCargoObservation(std::cell::Cell<usize>);
8821040
impl ObservationPort for ChangingCargoObservation {
8831041
fn classify(&self, path: &Path) -> io::Result<ObservedPathKind> {

0 commit comments

Comments
 (0)