符号链接陷阱频发,file_exists函数真的安全吗?

第一章:符号链接陷阱频发,file_exists函数真的安全吗?

在现代Web应用开发中,文件系统操作频繁且关键。然而,开发者常误以为调用 `file_exists` 函数足以验证文件的安全性和存在性,殊不知这一简单判断可能埋下严重安全隐患,尤其是在处理用户上传或动态路径时。

符号链接带来的潜在风险

符号链接(symlink)是一种特殊的文件类型,指向另一个文件或目录。攻击者可利用其绕过访问控制,读取敏感文件。例如,在Linux系统中创建指向 /etc/passwd 的符号链接,若程序未校验真实路径,便可能泄露系统信息。
  • 用户上传的文件路径未经净化处理
  • file_exists 返回 true 并不表示该路径是预期文件
  • 实际读取操作可能被符号链接重定向至受保护区域

安全替代方案与代码实践

应结合 realpath()is_file() 进行双重校验,确保路径未被符号链接篡改。

// 安全检查文件是否存在且非符号链接
$filePath = $_GET['file'];
$allowedDir = '/var/www/uploads/';

$realPath = realpath($filePath);

if (!$realPath || !is_file($realPath)) {
    die("无效文件");
}

// 检查解析后的路径是否在允许目录内
if (strpos($realPath, $allowedDir) !== 0) {
    die("访问被拒绝:路径越权");
}

echo "安全读取文件: " . file_get_contents($realPath);
上述代码首先通过 realpath() 解析路径,消除符号链接影响;再通过前缀比对确保文件位于白名单目录内,有效防御路径遍历攻击。
函数是否检测符号链接建议用途
file_exists()仅用于快速存在性判断
realpath() + is_file()安全文件访问前校验

第二章:深入理解file_exists函数的行为机制

2.1 file_exists函数的底层实现原理

系统调用与文件路径解析
PHP 的 `file_exists` 函数最终依赖于操作系统提供的 `stat` 系统调用。当调用该函数时,PHP 会将传入的文件路径传递给 Zend 引擎,由其封装为 `VCWD_STAT` 宏调用,兼容不同平台的路径处理。

// 简化后的 PHP 源码逻辑(ext/standard/filestat.c)
int php_file_stat(const char *filename, size_t filename_len, struct stat *ss, int type) {
    if (VCWD_STAT(filename, ss) == 0) {
        return 1; // 文件存在
    }
    return errno == ENOENT ? 0 : -1;
}
上述代码中,`VCWD_STAT` 是虚拟工作目录感知的 stat 调用,确保相对路径正确解析。若系统调用返回 0,表示文件存在且可访问;若错误码为 `ENOENT`(No such file or directory),则判定为不存在。
性能与缓存机制
频繁调用 `file_exists` 可能引发性能瓶颈,因其每次都会触发系统调用。建议结合 OPcache 或 APCu 对结果进行用户态缓存,减少内核交互开销。

2.2 符号链接对文件存在性判断的影响

在文件系统操作中,符号链接(Symbolic Link)的存在会影响程序对文件是否存在的判断逻辑。许多编程语言提供的“文件是否存在”函数默认会解析符号链接,导致实际检测的是目标文件而非链接本身。
常见语言中的行为差异
  • Python 的 os.path.exists() 会追踪符号链接,若目标不存在则返回 False
  • Go 语言中 os.Stat() 同样解析链接,而 os.Lstat() 则保留链接属性
info, err := os.Lstat("/path/to/symlink")
if err != nil {
    if os.IsNotExist(err) {
        // 链接本身不存在
    }
}
// Lstat 不追踪链接,可准确判断链接存在性
该代码使用 os.Lstat() 避免解析符号链接,从而正确识别链接文件的存在状态,适用于需要区分链接与目标的场景。

2.3 PHP中文件系统函数的安全上下文分析

在PHP应用开发中,文件系统函数如 file_get_contents()fopen()unlink() 常被用于读写服务器文件。然而,若未正确处理执行上下文,可能引发路径遍历、越权访问等安全问题。
危险函数示例与防护

// 危险用法:用户输入直接拼接路径
$filename = $_GET['file'];
readfile('/var/www/html/' . $filename); // 可能导致 ../../etc/passwd 被读取
上述代码未对输入进行过滤,攻击者可通过构造 ../../../etc/passwd 读取敏感系统文件。
安全实践建议
  • 使用 basename() 限制路径仅包含文件名
  • 结合 realpath() 校验路径是否在预期目录内
  • 启用 open_basedir 配置限制PHP可访问的目录范围

2.4 实验验证:file_exists在不同链接场景下的表现

为了验证 `file_exists` 函数在多种链接环境中的行为一致性,设计了本地文件、硬链接、软链接及网络挂载路径四类测试场景。
测试代码实现

// 测试各类路径是否存在
$paths = [
    'local'      => '/tmp/test.txt',
    'hard_link'  => '/tmp/hardlink_test.txt',
    'soft_link'  => '/tmp/softlink_test.txt',
    'nfs_mount'  => '/mnt/nfs/file.txt'
];

foreach ($paths as $type => $path) {
    echo "$type: " . (file_exists($path) ? 'exists' : 'not found') . "\n";
}
该脚本遍历四种路径类型,调用 `file_exists` 判断存在性。`file_exists` 对硬链接和符号链接均能正确解析目标文件状态,但在NFS等网络文件系统中受挂载状态与延迟影响可能出现短暂误判。
实验结果对比
路径类型file_exists返回值说明
本地文件直接访问inode,响应快且准确
硬链接共享同一inode,视为相同文件
软链接取决于目标存在性自动解引用后判断目标
NFS挂载文件可能延迟更新依赖网络与服务器状态

2.5 常见误用案例与安全风险归纳

不安全的输入处理
开发者常忽略对用户输入的校验,导致注入类漏洞频发。例如,在Go语言中直接拼接SQL语句:

query := "SELECT * FROM users WHERE name = '" + username + "'"
db.Query(query)
上述代码未使用参数化查询,攻击者可通过构造恶意用户名实现SQL注入。正确做法是使用预编译语句:

db.Query("SELECT * FROM users WHERE name = ?", username)
有效防止恶意SQL片段注入。
权限配置失当
常见的安全风险还包括过度授权。以下为典型误配置示例:
服务角色实际所需权限误配权限
日志读取器只读访问管理员权限
缓存清理任务删除操作全库写入
此类配置显著扩大攻击面,应遵循最小权限原则进行精细化控制。

第三章:符号链接攻击的原理与实战演示

3.1 符号链接劫持的基本构造方法

符号链接劫持是一种利用文件系统中符号链接的特性,将程序对合法文件的访问重定向至攻击者控制的恶意文件的技术。该方法常用于权限提升或绕过安全机制。
符号链接的创建与利用
在类Unix系统中,可通过`ln -s`命令创建符号链接:
ln -s /path/to/target /path/to/symlink
当目标程序以较高权限访问 /path/to/symlink时,实际操作的是 /path/to/target。若攻击者能控制目标路径,即可诱导程序读写恶意文件。
典型攻击流程
  • 监控目标程序将要访问的临时文件路径
  • 在文件创建前抢占式创建同名符号链接
  • 将链接指向敏感系统文件或配置文件
  • 触发目标程序执行,完成文件内容篡改

3.2 利用符号链接绕过文件校验的攻击链

在某些系统中,文件校验机制仅检查目标文件路径是否存在或验证文件属性,而未对符号链接进行解引用处理,攻击者可借此构造恶意软链接指向敏感文件,从而绕过安全检测。
符号链接伪造示例
# 创建指向 /etc/passwd 的符号链接
ln -s /etc/passwd evil_config.txt

# 应用程序误将符号链接当作普通配置文件读取
cat evil_config.txt  # 实际读取的是 /etc/passwd
上述命令创建了一个名为 evil_config.txt 的符号链接,指向系统关键文件。当校验逻辑未调用 realpath() 或使用 O_NOFOLLOW 标志时,会错误地将链接目标内容纳入处理流程。
典型攻击流程
  1. 攻击者上传带有符号链接的压缩包或配置文件
  2. 服务端解压或访问文件时不解析链接,直接读取内容
  3. 校验逻辑被绕过,敏感文件被泄露或篡改

3.3 模拟攻击:通过临时链接诱导业务逻辑错误

在现代Web应用中,临时链接常用于密码重置、邮件验证等场景。然而,若生成机制或校验逻辑存在缺陷,攻击者可利用其诱导业务流程异常。
漏洞原理
攻击者通过预测或重放临时链接,绕过正常操作流程。例如,一个有效期过长或令牌熵值不足的重置链接,可能导致账户被恶意劫持。
示例代码分析

// 低安全性临时链接生成方式
function generateResetToken() {
    return Math.random().toString(36).substr(2, 9); // 9字符随机字符串
}
const token = generateResetToken();
const resetLink = `https://example.com/reset?token=${token}`;
上述代码使用 Math.random() 生成令牌,其熵值不足且不可预测性弱,易被暴力破解。安全实现应使用加密级随机函数如 crypto.randomBytes()
防御建议
  • 使用高熵值令牌(至少128位)
  • 设置合理有效期(建议≤15分钟)
  • 令牌一次性使用,使用后立即失效

第四章:构建安全的文件存在性检查方案

4.1 使用realpath结合file_exists进行路径净化

在PHP文件操作中,路径安全性至关重要。攻击者可能通过目录遍历(如`../`)尝试访问或包含非预期文件。使用`realpath()`与`file_exists()`组合可有效净化并验证路径。
路径净化流程
  • realpath() 将相对路径转换为绝对路径,解析所有符号链接和../结构;
  • file_exists() 验证该路径是否真实存在。

$relative_path = $_GET['file'];
$clean_path = realpath($relative_path);

if ($clean_path !== false && file_exists($clean_path)) {
    // 安全读取文件
    readfile($clean_path);
} else {
    die('Invalid or non-existent file path.');
}
上述代码中, realpath() 确保路径被规范化,防止路径穿越。若输入为 ../../../etc/passwd,且不在允许目录内, realpath() 返回 false,请求被拒绝。此机制是构建安全文件访问的第一道防线。

4.2 借助is_link和lstat规避符号链接陷阱

在文件系统操作中,符号链接可能引发意料之外的行为,如无限递归或敏感文件泄露。为安全遍历目录,需准确识别链接文件。
链接类型判断
PHP 提供 `is_link()` 函数检测是否为符号链接,但无法获取链接目标的元信息。此时应结合 `lstat()` 使用,它不会跟随链接,而是返回链接本身的属性。

if (is_link($path)) {
    $info = lstat($path);
    echo "Link points to: " . readlink($path);
}
上述代码先判断路径是否为链接,再通过 `lstat()` 获取其状态,并用 `readlink()` 读取目标路径,避免误入恶意链接。
安全遍历策略
  • 始终在递归前检查 is_link() 状态
  • 使用 lstat() 替代 stat() 以防止自动解引用
  • 记录已访问的 inode 号,防止循环引用

4.3 安全封装:设计防篡改的文件检查函数

在构建可信系统时,确保文件完整性是关键环节。设计防篡改的检查函数需从输入验证、哈希计算到结果比对全程设防。
核心检查逻辑实现
func VerifyFileIntegrity(filePath string, expectedHash string) (bool, error) {
    file, err := os.Open(filePath)
    if err != nil {
        return false, err
    }
    defer file.Close()

    hash := sha256.New()
    if _, err := io.Copy(hash, file); err != nil {
        return false, err
    }
    actualHash := hex.EncodeToString(hash.Sum(nil))

    return actualHash == expectedHash, nil
}
该函数通过 SHA-256 计算文件哈希,避免使用弱哈希算法(如 MD5)。传入的 expectedHash 需通过安全信道获取,防止中间人篡改。
增强防护策略
  • 使用只读文件句柄,防止运行时被修改
  • 结合数字签名验证哈希值来源
  • 在内存隔离环境中执行校验逻辑

4.4 实践演练:在上传验证模块中防御符号链接攻击

在文件上传功能中,符号链接(Symbolic Link)攻击可能导致敏感路径被覆盖或系统文件被篡改。为防止此类风险,必须对上传路径进行规范化与安全校验。
防御策略设计
核心原则是禁止用户控制的文件名指向潜在危险路径。应使用安全的临时目录,并在保存前验证文件路径是否超出预期范围。
func validateUploadPath(filename string) (string, error) {
    // 构建目标路径
    targetPath := filepath.Join("/safe/upload/dir", filename)
    // 清理路径中的符号链接和相对路径
    cleanedPath, err := filepath.EvalSymlinks(targetPath)
    if err != nil && !os.IsNotExist(err) {
        return "", err
    }
    // 确保清理后的路径仍位于安全目录下
    if !strings.HasPrefix(cleanedPath, "/safe/upload/dir") {
        return "", fmt.Errorf("invalid path: attempted path traversal")
    }
    return cleanedPath, nil
}
上述代码通过 filepath.EvalSymlinks 解析并消除符号链接,再检查结果路径是否仍在允许范围内,有效阻止恶意链接绕过。
关键防护点
  • 始终在可信目录中处理上传文件
  • 禁用对用户上传文件的直接执行权限
  • 使用随机生成的文件名避免路径猜测

第五章:总结与安全编码最佳实践

输入验证与输出编码
所有外部输入必须视为不可信。使用白名单机制对用户输入进行校验,避免正则表达式过于宽松。例如,在 Go 中处理表单数据时:

func validateEmail(email string) bool {
    // 使用 regexp 包进行格式校验
    re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
    return re.MatchString(email)
}
同时,输出至 HTML 页面的数据应进行 HTML 实体编码,防止 XSS 攻击。
最小权限原则与依赖管理
应用运行时应使用最低必要权限账户。对于微服务架构,每个服务仅授予其所需的 API 访问权限。定期审查依赖库的安全性:
  • 使用 go list -m all | grep vulnerable 检查 Go 模块漏洞
  • 启用 SCA(软件成分分析)工具如 Snyk 或 Dependabot
  • 锁定依赖版本,避免自动升级引入风险
安全配置检查清单
配置项推荐值说明
HTTPS强制启用使用 TLS 1.3,禁用旧版协议
错误信息不暴露堆栈生产环境返回通用错误码
会话超时15 分钟无操作结合 Redis 实现分布式失效
自动化安全测试集成
在 CI/CD 流程中嵌入静态代码扫描(SAST)和依赖检测步骤。例如 GitHub Actions 工作流:

  - name: Run Snyk
    run: snyk test --severity-threshold=medium
  
# power/power_sync.py import json import os import re import logging import sys from pathlib import Path from shutil import copy2 from datetime import datetime from utils import resource_path from typing import Dict, List, Tuple, Any # ------------------------------- # 日志配置 # ------------------------------- PROJECT_ROOT = Path(__file__).parent.parent.resolve() LOG_DIR = PROJECT_ROOT / "output" / "log" LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_FILE = LOG_DIR / f"power_sync_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" class PowerTableSynchronizer: def __init__(self, c_file_path=None, dry_run=False, config_path="config/config.json"): self.logger = logging.getLogger(__name__) # === Step 1: 使用 resource_path 解析所有路径 === self.config_file_path = resource_path(config_path) logging.info(f"配置文件: {self.config_file_path}") if not os.path.exists(self.config_file_path): raise FileNotFoundError(f"配置文件不存在: {self.config_file_path}") try: with open(self.config_file_path, 'r', encoding='utf-8') as f: self.config = json.load(f) print(f"配置文件已加载: {self.config_file_path}") except json.JSONDecodeError as e: raise ValueError(f"配置文件格式错误,JSON 解析失败: {self.config_file_path}") from e except Exception as e: raise RuntimeError(f"读取配置文件时发生未知错误: {e}") from e self.dry_run = dry_run # === Step 2: 目标 C 文件处理 === if c_file_path is None: if "target_c_file" not in self.config: raise KeyError("config 文件缺少 'target_c_file' 字段") internal_c_path = self.config["target_c_file"] logging.info(f"使用内置 C 文件: {internal_c_path}") self.c_file_path =resource_path(internal_c_path) self._is_internal_c_file = True else: self.c_file_path = Path(c_file_path) self._is_internal_c_file = False if not self.c_file_path.exists(): raise FileNotFoundError(f"找不到 C 源文件: {self.c_file_path}") # === Step 3: 初始化数据容器 === self.locale_enums = {} # enum_name -> {"macros": [macro], "values": {macro: idx}} self.power_tables = {} # table_name -> [lines] self.used_locales = [] # ["DEFAULT", "CUSTOM1"] # === Step 4: 加载锚点标记 === for marker_key in ["STR_POWER_LOCALE_ENUM", "END_POWER_LOCALE_ENUM", "STR_POWER_TABLE", "END_POWER_TABLE"]: if marker_key not in self.config: raise KeyError(f"config 文件缺少 '{marker_key}' 字段") self.start_enum_marker = self.config["STR_POWER_LOCALE_ENUM"] self.end_enum_marker = self.config["END_POWER_LOCALE_ENUM"] self.start_table_marker = self.config["STR_POWER_TABLE"] self.end_table_marker = self.config["END_POWER_TABLE"] def offset_to_lineno(self, content: str, offset: int) -> int: """将字符偏移量转换为行号(从1开始)""" return content.count('\n', 0, offset) + 1 def load_used_locales(self): """从 config.json 加载 used_locales 列表,并保持原有顺序""" if "used_locales" not in self.config: raise KeyError("config 文件缺少 'used_locales' 字段") valid_locales = [] seen = set() for item in self.config["used_locales"]: if isinstance(item, str) and re.match(r'^[A-Z0-9_]+$', item): if item not in seen: valid_locales.append(item) seen.add(item) else: self.logger.warning(f"跳过无效 Locale 名称: {item}") self.used_locales = valid_locales self.logger.info(f"已从 config 加载 {len(self.used_locales)} 个有效 Locale(保持顺序): {self.used_locales}") def parse_c_power_definitions(self): """解析 C 文件中的 enum locale_xxx_idx 和 static const unsigned char locales_xxx[]""" content = self.c_file_path.read_text(encoding='utf-8') # --- 解析 ENUM 区域 --- try: enum_start_idx = content.find(self.start_enum_marker) enum_end_idx = content.find(self.end_enum_marker) if enum_start_idx == -1 or enum_end_idx == -1: raise ValueError("未找到 LOCALE ENUM 标记块") enum_block = content[enum_start_idx:enum_end_idx] start_line = self.offset_to_lineno(content, enum_start_idx) end_line = self.offset_to_lineno(content, enum_end_idx) self.logger.info(f"找到 ENUM 标记范围:第 {start_line} 行 → 第 {end_line} 行") enum_pattern = re.compile( r'(enum\s+locale_[\w\d_]+_idx\s*\{)([^}]*)\}\s*;', re.DOTALL | re.IGNORECASE ) for match in enum_pattern.finditer(enum_block): enum_decl = match.group(0) enum_name_match = re.search(r'locale_[\w\d_]+_idx', enum_decl, re.IGNORECASE) if not enum_name_match: continue enum_name = enum_name_match.group(0).lower() body = match.group(2) macros_with_values = re.findall(r'(LOCALE_[\w\d_]+)\s*=\s*(\d+)', (body)) macro_list = [m[0] for m in macros_with_values] value_map = {m: int(v) for m, v in macros_with_values} self.locale_enums[enum_name] = { "macros": macro_list, "values": value_map } self.logger.info(f" 解析枚举 {enum_name}: {len(macro_list)} 个宏") except Exception as e: self.logger.error(f"解析 ENUM 失败: {e}", exc_info=True) # --- 解析 TABLE 区域 --- try: table_start_idx = content.find(self.start_table_marker) table_end_idx = content.find(self.end_table_marker) if table_start_idx == -1 or table_end_idx == -1: raise ValueError("未找到 POWER TABLE 标记块") table_block = content[table_start_idx:table_end_idx] start_line = self.offset_to_lineno(content, table_start_idx) end_line = self.offset_to_lineno(content, table_end_idx) self.logger.info(f"找到 TABLE 标记范围:第 {start_line} 行 → 第 {end_line} 行") array_pattern = re.compile( r'(static\s+const\s+unsigned\s+char\s+)([\w\d_]+)\s*\[\s*\]\s*=\s*\{(.*?)\}\s*;', re.DOTALL | re.IGNORECASE ) for match in array_pattern.finditer(table_block): table_name = match.group(2) body_content = match.group(3) lines = [line.strip() for line in body_content.split(',') if line.strip()] cleaned_lines = [] for line in lines: clean = re.sub(r'/\*.*?\*/', '', line).strip() if clean and not clean.startswith('//'): cleaned_lines.append(clean) self.power_tables[table_name] = cleaned_lines self.logger.info(f" 解析数组 {table_name}: {len(cleaned_lines)} 行数据") except Exception as e: self.logger.error(f"解析 TABLE 失败: {e}", exc_info=True) def validate_and_repair(self): """根据 used_locales 的位置,将第1个放入 base 表,第2个放入 ht 表""" modified = False changes = [] # 提前加载所有 Locale 数据 all_locale_data = self.extract_all_raw_locale_data() missing_locales = [] for idx, locale in enumerate(self.used_locales): if idx >= 6: self.logger.warning(f" 跳过超出限制的 Locale(最多6个): {locale}") continue if locale not in all_locale_data: missing_locales.append(locale) self.logger.warning(f" 未在 tx_limit_table.c 中找到数据: {locale}") continue data_lines = all_locale_data[locale] # 直接获取字符串列表 # === 根据索引决定目标 === if idx == 0: # 第1个 → 2.4G Base enum_name = "locale_2g_idx" table_name = "locales_2g_base" macro_suffix = "2G_IDX" elif idx == 1: # 第2个 → 2.4G HT enum_name = "locale_2g_ht_idx" table_name = "locales_2g_ht" macro_suffix = "2G_HT_IDX" else: self.logger.warning(f" 当前仅支持前两个 Locale(idx=0,1),跳过 idx={idx}: {locale}") continue # === 生成宏名 === macro_name = f"LOCALE_{macro_suffix}_{locale}" # --- 处理 ENUM --- if enum_name not in self.locale_enums: self.logger.warning(f"未找到枚举定义: {enum_name}") continue macros = self.locale_enums[enum_name]["macros"] values = self.locale_enums[enum_name]["values"] if macro_name not in macros: next_idx = len(macros) macros.append(macro_name) values[macro_name] = next_idx changes.append(f"ENUM + {macro_name} = {next_idx}") modified = True # --- 处理 TABLE --- if table_name not in self.power_tables: self.power_tables[table_name] = [] changes.append(f"TABLE 初始化: {table_name}") current_entries = self.power_tables[table_name] # 防止重复插入(比较第一行) if not any(d == data_lines[0] for d in current_entries): self.power_tables[table_name].extend(data_lines) changes.append(f"TABLE + {len(data_lines)} 行 → {table_name}") modified = True if missing_locales: error_msg = f" 以下 Locale 未在 tx_limit_table.c 中找到,请先运行 excel_to_clm.py: {missing_locales}" self.logger.error(error_msg) raise RuntimeError(error_msg) # === 写回文件 === if modified and not self.dry_run: self._write_back_in_blocks() self.logger.info(" C 文件已更新") elif modified: self.logger.info(" DRY-RUN MODE: 有变更但不会写入文件") else: self.logger.info(" 所有 Locale 已存在,无需修改") if changes: self.logger.info(f"共需添加 {len(changes)} 项:\n" + "\n".join(f" → {ch}" for ch in changes)) return modified def extract_all_raw_locale_data(self) -> Dict[str, List[str]]: """ 从 output/tx_limit_table.c 中提取所有 /* Locale XXX */ 后面的数据块(直到下一个 Locale 或 EOF) 使用逐行解析,避免正则不匹配问题 """ gen_file = PROJECT_ROOT / "output" / "tx_limit_table.c" if not gen_file.exists(): self.logger.error(f"❌ 找不到生成文件: {gen_file}") raise FileNotFoundError(f"请先运行 excel_to_clm.py 生成 tx_limit_table.c: {gen_file}") try: content = gen_file.read_text(encoding='utf-8') except Exception as e: self.logger.error(f"❌ 读取 {gen_file} 失败: {e}") raise self.logger.debug(f"📄 正在解析文件: {gen_file}") self.logger.debug(f"🔍 前 300 字符:\n{content[:300]}") lines = content.splitlines() locale_data = {} current_locale = None current_block = [] for i, line in enumerate(lines): stripped = line.strip() # 检查是否是新的 Locale 标记 match = re.match(r'/\*\s*Locale\s+([A-Za-z0-9_]+)\s*\*/', stripped, re.IGNORECASE) if match: # 保存上一个 block if current_locale: # 清理 block:去空行、去注释、去尾逗号 cleaned = [ re.sub(r'/\*.*?\*/|//.*', '', ln).strip().rstrip(',') for ln in current_block if re.sub(r'/\*.*?\*/|//.*', '', ln).strip() ] locale_data[current_locale] = cleaned self.logger.debug(f"📌 已提取 Locale {current_locale},共 {len(cleaned)} 行") # 开始新 block current_locale = match.group(1) current_block = [] self.logger.debug(f"🆕 发现 Locale: {current_locale}") continue # 收集当前 locale 的内容 if current_locale is not None: current_block.append(line) # 别忘了最后一个 block if current_locale: cleaned = [ re.sub(r'/\*.*?\*/|//.*', '', ln).strip().rstrip(',') for ln in current_block if re.sub(r'/\*.*?\*/|//.*', '', ln).strip() ] locale_data[current_locale] = cleaned self.logger.debug(f" 已提取最后 Locale {current_locale},共 {len(cleaned)} 行") self.logger.info(f" 成功提取 {len(locale_data)} 个 Locale 数据块: {list(locale_data.keys())}") return locale_data def _write_back_in_blocks(self): """将修改后的 enum 和 table 块写回原 C 文件,精准插入,不破坏原始格式""" if self.dry_run: self.logger.info("DRY-RUN: 跳过写入文件") return try: content = self.c_file_path.read_text(encoding='utf-8') backup_path = self.c_file_path.with_suffix(self.c_file_path.suffix + ".bak") copy2(self.c_file_path, backup_path) self.logger.info(f" 已备份原文件 → {backup_path}") replacements = [] # (start_offset, end_offset, replacement) def remove_comments(text): text = re.sub(r'//.*$', '', text, flags=re.MULTILINE) text = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL) return text # === 1. 更新 ENUM locale_2g_idx 和 locale_2g_ht_idx === for enum_name_key in ["locale_2g_idx", "locale_2g_ht_idx"]: if enum_name_key not in self.locale_enums: continue enum_data = self.locale_enums[enum_name_key] pattern = re.compile( rf'(enum\s+{re.escape(enum_name_key)}\s*\{{)([^}}]*)\}}\s*;', re.DOTALL | re.IGNORECASE ) match = pattern.search(content) if not match: self.logger.warning(f"未找到枚举定义: {enum_name_key}") continue header = match.group(1) # 包括 'enum ... {' body = match.group(2) full_start = match.start() full_end = match.end() macro_name = f"LOCALE_{enum_name_key.upper()}_{self.used_locales[0] if enum_name_key == 'locale_2g_idx' else self.used_locales[1]}" if macro_name in enum_data["macros"]: continue # 已存在 next_idx = len([m for m in enum_data["macros"] if not m.startswith("CLM_LOC_")]) enum_data["macros"].append(macro_name) enum_data["values"][macro_name] = next_idx # 分析最后一行格式 lines = [ln for ln in body.split('\n') if ln.strip()] last_line = lines[-1] if lines else "" clean_last = remove_comments(last_line) indent_match = re.match(r'^(\s*)', last_line) line_indent = indent_match.group(1) if indent_match else " " expanded_last = last_line.expandtabs(4) first_macro_match = re.search(r'LOCALE_[A-Z0-9_]+', clean_last) if first_macro_match: raw_before = last_line[:first_macro_match.start()] expanded_before = raw_before.expandtabs(4) target_macro_col = len(expanded_before) else: target_macro_col = len(line_indent.replace('\t', ' ')) eq_match = re.search(r'=\s*\d+', clean_last) if eq_match and first_macro_match: eq_abs_start = first_macro_match.start() + eq_match.start() raw_eq_part = last_line[:eq_abs_start] expanded_eq_part = raw_eq_part.expandtabs(4) target_eq_col = len(expanded_eq_part) else: target_eq_col = target_macro_col + len(macro_name) + 6 # fallback current_visual_len = len(macro_name.replace('\t', ' ')) padding_to_eq = max(1, target_eq_col - target_macro_col - current_visual_len) formatted_macro = f"{macro_name}{' ' * padding_to_eq}= {next_idx}" visible_macros = len(re.findall(r'LOCALE_[A-Z0-9_]+', clean_last)) MAX_PER_LINE = 4 # 根据你实际排版调整 if visible_macros < MAX_PER_LINE and last_line.strip(): insertion = f" {formatted_macro}," updated_last = last_line.rstrip() + insertion new_body = body.rsplit(last_line, 1)[0] + updated_last else: raw_indent_len = len(line_indent.replace('\t', ' ')) leading_spaces = max(0, target_macro_col - raw_indent_len) prefix_padding = ' ' * leading_spaces new_line = f"\n{line_indent}{prefix_padding}{formatted_macro}," trailing = body.rstrip() maybe_comma = "," if not trailing.endswith(',') else "" new_body = f"{trailing}{maybe_comma}{new_line}" new_enum = f"{header}{new_body}\n}};" replacements.append((full_start, full_end, new_enum)) self.logger.debug(f" 插入 ENUM: {macro_name} = {next_idx}") # === 2. 更新 TABLE locales_2g_base 和 locales_2g_ht === for table_name in ["locales_2g_base", "locales_2g_ht"]: if table_name not in self.power_tables: self.logger.info(f"没有需要更新的表: {table_name}") continue target_locale = self.used_locales[0] if table_name == "locales_2g_base" else self.used_locales[1] all_data = self.extract_all_raw_locale_data() if target_locale not in all_data: continue new_lines = all_data[target_locale] current_count = len(self.power_tables[table_name]) data_to_insert = new_lines[current_count:] if not data_to_insert: continue pattern = re.compile( rf'(\b{re.escape(table_name)}\s*\[\s*\]\s*=\s*\{{)(.*?)(\}}\s*;\s*)', re.DOTALL | re.IGNORECASE ) match = pattern.search(content) if not match: self.logger.warning(f"未找到数组: {table_name}") continue header_part = match.group(1) body_content = match.group(2) footer_part = match.group(3) full_start = match.start() full_end = match.end() # 分析最后一行结构体格式 lines = [ln for ln in body_content.split('\n') if ln.strip()] last_line = lines[-1] if lines else "" indent_match = re.match(r'^(\s*)', last_line) line_indent = indent_match.group(1) if indent_match else " " expanded_last = last_line.expandtabs(4) first_struct_match = re.search(r'\{', remove_comments(last_line)) if first_struct_match: raw_before = last_line[:first_struct_match.start()] expanded_before = raw_before.expandtabs(4) target_struct_col = len(expanded_before) else: target_struct_col = len(line_indent.replace('\t', ' ')) raw_indent_len = len(line_indent.replace('\t', ' ')) leading_spaces = max(0, target_struct_col - raw_indent_len) prefix_padding = ' ' * leading_spaces # 构造新行 new_block = "" for item in data_to_insert: item_clean = item.rstrip(',').strip() new_block += f"\n{line_indent}{prefix_padding}{item_clean}," new_body = body_content.rstrip() + new_block new_table = f"{header_part}{new_body}\n{footer_part}" replacements.append((full_start, full_end, new_table)) self.logger.debug(f"📌 插入 {len(data_to_insert)} 行到 {table_name}") # === 应用替换(倒序)=== replacements.sort(key=lambda x: x[0], reverse=True) final_content = content for start, end, r in replacements: final_content = final_content[:start] + r + final_content[end:] # === 写回文件 === self.c_file_path.write_text(final_content, encoding='utf-8') self.logger.info(f" 成功写回 C 文件: {self.c_file_path}") except Exception as e: self.logger.error(f"写回文件失败: {e}", exc_info=True) raise def run(self): self.logger.info("开始同步 POWER LOCALE 定义...") try: self.load_used_locales() self.parse_c_power_definitions() was_modified = self.validate_and_repair() if was_modified: if self.dry_run: self.logger.info("预览模式:检测到变更,但不会写入文件") else: self.logger.info("同步完成:已成功更新 C 文件") else: self.logger.info("所有 Locale 已存在,无需修改") return was_modified except Exception as e: self.logger.error(f"同步失败: {e}", exc_info=True) raise def main(): logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', handlers=[ logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout) ], force=True ) logger = logging.getLogger(__name__) # 固定配置 c_file_path = "input/wlc_clm_data_6726b0.c" dry_run = False log_level = "INFO" config_path = "config/config.json" logging.getLogger().setLevel(log_level) print(f"开始同步 POWER LOCALE 定义...") print(f"C 源文件: {c_file_path}") if dry_run: print("启用 dry-run 模式:仅预览变更,不修改文件") try: sync = PowerTableSynchronizer( c_file_path=None, dry_run=dry_run, config_path=config_path, ) sync.run() print("同步完成!") print(f"详细日志已保存至: {LOG_FILE}") except FileNotFoundError as e: logger.error(f"文件未找到: {e}") print("请检查文件路径是否正确。") sys.exit(1) except PermissionError as e: logger.error(f"权限错误: {e}") print("无法读取或写入文件,请检查权限。") sys.exit(1) except Exception as e: logger.error(f"程序异常退出: {e}", exc_info=True) sys.exit(1) if __name__ == '__main__': main() 能不能让写入和validate_and_repair明确分工
10-23
file_exists()函数是用来判断文件或目录是否存在的。它可以同时判断文件和目录的存在性。然而,由于其同时判断文件和目录,所以执行效率相对较低,远远不及is_file()函数。is_file()函数只能判断文件是否存在,不能判断目录是否存在。\[1\] 在R语言中,可以使用fs包中的file_copy()函数、dir_copy()函数和link_copy()函数来将文件、目录和超链接从一个位置拷贝到另一个位置。这些函数可以方便地进行文件操作和拷贝。\[2\] 有时候使用file_exists()方法判断文件是否存在时,明明文件是存在的,却始终无法找到文件显示不存在。这可能是因为文件的权限设置不正确。可以尝试将整个目录设置为777权限(chmod 777 -R 目录),然后再次使用file_exists()方法判断文件是否存在。\[3\] #### 引用[.reference_title] - *1* [php 检查文件是否存在函数file_exists与is_file的区别](https://blog.youkuaiyun.com/weixin_42356552/article/details/115259321)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [R语言使用fs包的file_access函数file_exists函数、dir_exists函数、link_exists函数分别查看文件是否可以...](https://blog.youkuaiyun.com/zhongkeyuanchongqing/article/details/122325773)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [PHP file_exists()有什么用?](https://blog.youkuaiyun.com/weixin_28698129/article/details/115664150)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值