Skip to content

Commit 2ace496

Browse files
committed
feat: 区分项目本地原生工具候选与安装建议
1 parent 3a99f2f commit 2ace496

12 files changed

Lines changed: 484 additions & 5 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// ESLint 原配置的只读观察状态;静态存在不等于原生配置有效。
2+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3+
pub enum EslintConfigState {
4+
/// 已观察到配置文件,动态加载和规则仍待原生核验。
5+
Observed,
6+
/// 配置文件或其声明已确定无效。
7+
Invalid,
8+
/// 配置尚未可判定。
9+
Unknown,
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// 项目本地 ESLint 的只读准备候选;不能单独授权执行或证明 lint 覆盖。
2+
#[derive(Clone, Debug, Eq, PartialEq)]
3+
pub struct EslintLocalCandidate {
4+
/// 结构化候选状态,绝不把 PATH 不可见等同工具缺失。
5+
pub state: &'static str,
6+
/// 根 package.json 中的原始 ESLint 版本声明(如有)。
7+
pub declared_spec: Option<String>,
8+
/// 项目本地 ESLint package.json 中的具体版本(如有效)。
9+
pub observed_version: Option<String>,
10+
/// 供智能体继续核验的具体动作,不是自动安装指令。
11+
pub next_action: &'static str,
12+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
use codeguard_core::ObservedPathKind;
2+
use serde_json::Value;
3+
4+
use crate::{EslintConfigState, EslintLocalCandidate, strict_json::parse_unique_json};
5+
6+
/// 只读核对项目声明、本地包身份、原生入口与配置状态;不搜索 PATH 或执行 JS。
7+
/// 输入由调用方有界读取并分类;返回候选,不证明 Node 或 ESLint 本轮执行成功。
8+
pub fn inspect_eslint_local_candidate(
9+
project_manifest: Option<&[u8]>,
10+
local_manifest: Option<&[u8]>,
11+
entry_kind: Option<ObservedPathKind>,
12+
config: EslintConfigState,
13+
) -> EslintLocalCandidate {
14+
let declared_spec = match project_manifest.map(parse_project_declaration).transpose() {
15+
Ok(value) => value.flatten(),
16+
Err(()) => {
17+
return candidate(
18+
"project_manifest_invalid",
19+
None,
20+
None,
21+
"修正项目 package.json 的 JSON 或 ESLint 依赖声明后重查本地工具身份",
22+
);
23+
}
24+
};
25+
let observed_version = match local_manifest.map(parse_local_identity).transpose() {
26+
Ok(value) => value,
27+
Err(()) => {
28+
return candidate(
29+
"local_package_identity_invalid",
30+
declared_spec,
31+
None,
32+
"核对项目本地 ESLint package.json、版本和 bin 入口;不要把损坏制品说成源码违规",
33+
);
34+
}
35+
};
36+
if config == EslintConfigState::Invalid {
37+
return candidate(
38+
"configuration_invalid",
39+
declared_spec,
40+
observed_version,
41+
"修正项目原 ESLint 配置后用已发现的原生工具复检",
42+
);
43+
}
44+
if matches!(
45+
entry_kind,
46+
Some(ObservedPathKind::Symlink | ObservedPathKind::Directory | ObservedPathKind::Other)
47+
) {
48+
return candidate(
49+
"local_entry_untrusted",
50+
declared_spec,
51+
observed_version,
52+
"核对项目本地 ESLint 入口的真实类型与来源,不跟随未知链接执行",
53+
);
54+
}
55+
if entry_kind == Some(ObservedPathKind::File) {
56+
return match observed_version {
57+
Some(version)
58+
if declared_spec
59+
.as_deref()
60+
.and_then(|spec| semver::Version::parse(spec).ok())
61+
.is_some_and(|exact| exact.to_string() != version) =>
62+
{
63+
candidate(
64+
"local_version_conflicts_with_declaration",
65+
declared_spec,
66+
Some(version),
67+
"核对项目固定声明与本地 ESLint 包版本,按原项目依赖方案恢复一致后原生复检",
68+
)
69+
}
70+
Some(version) if supported_version(&version) => candidate(
71+
"local_candidate_requires_native_probe",
72+
declared_spec,
73+
Some(version),
74+
"用明确的 Node 路径、项目本地 ESLint 入口及原配置执行版本和规则核验",
75+
),
76+
Some(version) => candidate(
77+
"local_version_not_supported_by_adapter",
78+
declared_spec,
79+
Some(version),
80+
"核对项目实际 ESLint 版本并补齐相应原生适配器,不按旧版结果放行",
81+
),
82+
None => candidate(
83+
"local_entry_identity_unknown",
84+
declared_spec,
85+
None,
86+
"读取并核对项目本地 ESLint 包清单和入口版本后再执行,不根据 PATH 猜测",
87+
),
88+
};
89+
}
90+
if declared_spec.is_some() || observed_version.is_some() {
91+
candidate(
92+
"declared_local_entry_unobserved",
93+
declared_spec,
94+
observed_version,
95+
"核对项目本地入口、包管理器布局或显式工具路径;不能仅凭 PATH 判定未安装",
96+
)
97+
} else {
98+
candidate(
99+
"unknown",
100+
None,
101+
None,
102+
"确认项目是否要求 ESLint,以及其本地或显式原生工具位置与配置",
103+
)
104+
}
105+
}
106+
107+
fn parse_project_declaration(raw: &[u8]) -> Result<Option<String>, ()> {
108+
let object = parsed_object(raw)?;
109+
let mut declared: Option<&str> = None;
110+
for field in [
111+
"dependencies",
112+
"devDependencies",
113+
"optionalDependencies",
114+
"peerDependencies",
115+
] {
116+
let Some(group) = object.get(field) else {
117+
continue;
118+
};
119+
let dependencies = group.as_object().ok_or(())?;
120+
if let Some(raw_spec) = dependencies.get("eslint") {
121+
let spec = raw_spec
122+
.as_str()
123+
.filter(|value| valid_spec(value))
124+
.ok_or(())?;
125+
if declared.is_some_and(|previous| previous != spec) {
126+
return Err(());
127+
}
128+
declared = Some(spec);
129+
}
130+
}
131+
Ok(declared.map(str::to_owned))
132+
}
133+
134+
fn parse_local_identity(raw: &[u8]) -> Result<String, ()> {
135+
let object = parsed_object(raw)?;
136+
if object.get("name").and_then(Value::as_str) != Some("eslint") {
137+
return Err(());
138+
}
139+
let version = object.get("version").and_then(Value::as_str).ok_or(())?;
140+
let parsed = semver::Version::parse(version).map_err(|_| ())?;
141+
if !parsed.pre.is_empty() || !parsed.build.is_empty() {
142+
return Err(());
143+
}
144+
let entry = match object.get("bin") {
145+
Some(Value::String(path)) => Some(path.as_str()),
146+
Some(Value::Object(bins)) => bins.get("eslint").and_then(Value::as_str),
147+
_ => None,
148+
};
149+
if entry != Some("bin/eslint.js") {
150+
return Err(());
151+
}
152+
Ok(version.into())
153+
}
154+
155+
fn parsed_object(raw: &[u8]) -> Result<serde_json::Map<String, Value>, ()> {
156+
if raw.is_empty() || raw.len() > 256 * 1024 {
157+
return Err(());
158+
}
159+
parse_unique_json(raw)
160+
.map_err(|_| ())?
161+
.as_object()
162+
.cloned()
163+
.ok_or(())
164+
}
165+
166+
fn valid_spec(value: &str) -> bool {
167+
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
168+
}
169+
170+
fn supported_version(version: &str) -> bool {
171+
semver::Version::parse(version).is_ok_and(|parsed| parsed.major == 10)
172+
}
173+
174+
fn candidate(
175+
state: &'static str,
176+
declared_spec: Option<String>,
177+
observed_version: Option<String>,
178+
next_action: &'static str,
179+
) -> EslintLocalCandidate {
180+
EslintLocalCandidate {
181+
state,
182+
declared_spec,
183+
observed_version,
184+
next_action,
185+
}
186+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ mod checkstyle_parsed;
2727
mod checkstyle_result;
2828
mod checkstyle_rule_binding;
2929
mod eslint_command;
30+
mod eslint_config_state;
31+
mod eslint_local_candidate;
32+
mod eslint_local_readiness;
3033
mod npm_audit_command;
3134
mod npm_locked_node;
3235
mod rustdoc_finding;
@@ -41,6 +44,9 @@ mod npm_audit_observation;
4144
mod source_map;
4245
mod source_mapped_recovery;
4346
mod strict_json;
47+
pub use eslint_config_state::EslintConfigState;
48+
pub use eslint_local_candidate::EslintLocalCandidate;
49+
pub use eslint_local_readiness::inspect_eslint_local_candidate;
4450
pub use npm_audit_component::NpmAuditComponent;
4551
pub use npm_audit_json::parse_npm_audit_json;
4652
pub use npm_audit_observation::NpmAuditObservation;
@@ -69,6 +75,8 @@ mod maven_dependency_pom;
6975
mod maven_dependency_tree;
7076
mod maven_javadoc_output;
7177
mod maven_module_model;
78+
mod maven_wrapper_candidate;
79+
mod maven_wrapper_readiness;
7280
mod owasp_dependency_check;
7381
mod owasp_maven_attribution;
7482
mod owasp_maven_pom;
@@ -126,6 +134,8 @@ pub use maven_javadoc_output::{
126134
MavenJavadocDiagnostic, MavenJavadocParseState, MavenJavadocParsed, parse_maven_javadoc_output,
127135
};
128136
pub use maven_module_model::MavenModuleModel;
137+
pub use maven_wrapper_candidate::MavenWrapperCandidate;
138+
pub use maven_wrapper_readiness::inspect_maven_wrapper_candidate;
129139
pub use owasp_dependency_check::{
130140
OwaspAdvisoryObservation, OwaspDependencyCheckReport, parse_owasp_dependency_check_json,
131141
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// Maven Wrapper 的只读准备状态;候选不表示已执行 Maven。
2+
#[derive(Debug, Clone, PartialEq, Eq)]
3+
pub struct MavenWrapperCandidate {
4+
/// 观察到的具体状态。
5+
pub state: &'static str,
6+
/// 从固定 distributionUrl 观察到的 Maven 版本。
7+
pub observed_version: Option<String>,
8+
/// 下一步核验或修复建议。
9+
pub next_action: &'static str,
10+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use codeguard_core::ObservedPathKind;
2+
3+
use crate::MavenWrapperCandidate;
4+
5+
/// 只读核对 Maven Wrapper 脚本和配置;不查 PATH、不执行脚本,也不下载发行包。
6+
/// 参数是调用方分类后的脚本类型及有界读取的 properties;返回候选而非检查通过。
7+
pub fn inspect_maven_wrapper_candidate(
8+
script_kind: Option<ObservedPathKind>,
9+
properties: Option<&[u8]>,
10+
) -> MavenWrapperCandidate {
11+
let version = match properties.map(parse_distribution_version).transpose() {
12+
Ok(value) => value,
13+
Err(()) => {
14+
return candidate(
15+
"wrapper_configuration_invalid",
16+
None,
17+
"修正 Maven Wrapper 的 distributionUrl 配置,再核对原生 Maven 版本",
18+
);
19+
}
20+
};
21+
if matches!(
22+
script_kind,
23+
Some(ObservedPathKind::Symlink | ObservedPathKind::Directory | ObservedPathKind::Other)
24+
) {
25+
return candidate(
26+
"wrapper_script_untrusted",
27+
version,
28+
"核对 Maven Wrapper 脚本的真实类型与来源,不跟随未知链接执行",
29+
);
30+
}
31+
match (script_kind, version) {
32+
(Some(ObservedPathKind::File), Some(version)) => {
33+
let state = if semver::Version::parse(&version).is_ok_and(|parsed| parsed.major == 3) {
34+
"wrapper_candidate_requires_native_probe"
35+
} else {
36+
"wrapper_version_not_supported_by_adapter"
37+
};
38+
let action = if state == "wrapper_candidate_requires_native_probe" {
39+
"以受控方式执行项目 Maven Wrapper 的版本探针,再运行项目原生检查"
40+
} else {
41+
"核对项目 Maven 版本并补齐相应适配器,不按旧版结果放行"
42+
};
43+
candidate(state, Some(version), action)
44+
}
45+
(None, Some(version)) => candidate(
46+
"wrapper_script_unobserved",
47+
Some(version),
48+
"核对项目 Maven Wrapper 脚本或显式 Maven 路径,不能仅凭 PATH 判定未安装",
49+
),
50+
(Some(ObservedPathKind::File), None) => candidate(
51+
"wrapper_configuration_unobserved",
52+
None,
53+
"核对 Maven Wrapper 配置或显式 Maven 路径,不能仅凭 PATH 判定未安装",
54+
),
55+
(None, None) => candidate(
56+
"unknown",
57+
None,
58+
"确认项目构建器、Wrapper 和显式 Maven 路径,再判断原生检查准备状态",
59+
),
60+
_ => unreachable!("已处理全部非文件脚本类型"),
61+
}
62+
}
63+
64+
fn parse_distribution_version(raw: &[u8]) -> Result<String, ()> {
65+
if raw.is_empty() || raw.len() > 64 * 1024 {
66+
return Err(());
67+
}
68+
let text = std::str::from_utf8(raw).map_err(|_| ())?;
69+
let mut distribution_url: Option<&str> = None;
70+
for line in text.lines() {
71+
let line = line.trim();
72+
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
73+
continue;
74+
}
75+
let Some((key, value)) = line.split_once('=') else {
76+
continue;
77+
};
78+
if key.trim() == "distributionUrl" {
79+
if distribution_url.is_some() {
80+
return Err(());
81+
}
82+
distribution_url = Some(value.trim());
83+
}
84+
}
85+
let url = distribution_url.ok_or(())?.replace("\\:", ":");
86+
let filename = url.rsplit('/').next().ok_or(())?;
87+
let without_prefix = filename.strip_prefix("apache-maven-").ok_or(())?;
88+
let version = without_prefix
89+
.strip_suffix("-bin.zip")
90+
.or_else(|| without_prefix.strip_suffix("-bin.tar.gz"))
91+
.ok_or(())?;
92+
let parsed = semver::Version::parse(version).map_err(|_| ())?;
93+
if !parsed.pre.is_empty() || !parsed.build.is_empty() {
94+
return Err(());
95+
}
96+
Ok(version.to_owned())
97+
}
98+
99+
fn candidate(
100+
state: &'static str,
101+
observed_version: Option<String>,
102+
next_action: &'static str,
103+
) -> MavenWrapperCandidate {
104+
MavenWrapperCandidate {
105+
state,
106+
observed_version,
107+
next_action,
108+
}
109+
}

0 commit comments

Comments
 (0)