Skip to content
Merged
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
13 changes: 13 additions & 0 deletions backend/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ add_library(jobpilot_core STATIC
src/core/core.cpp
src/core/logger.cpp
src/core/thread_pool.cpp
src/core/utf8.cpp
src/network/channel.cpp
src/network/epoll_poller.cpp
src/network/event_loop.cpp
Expand Down Expand Up @@ -154,6 +155,16 @@ target_link_libraries(test_config
GTest::gtest_main
)

add_executable(test_utf8
tests/test_utf8.cpp
)

target_link_libraries(test_utf8
PRIVATE
jobpilot_core
GTest::gtest_main
)

add_executable(test_logger
tests/test_logger.cpp
)
Expand Down Expand Up @@ -518,6 +529,7 @@ if(JP_ENABLE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux")

set_target_properties(
test_config
test_utf8
test_logger
test_http_correctness
test_schema
Expand Down Expand Up @@ -552,6 +564,7 @@ endif()
include(GoogleTest)

gtest_discover_tests(test_config)
gtest_discover_tests(test_utf8)
gtest_discover_tests(test_logger)
gtest_discover_tests(test_sql_result)
gtest_discover_tests(test_http_correctness)
Expand Down
11 changes: 11 additions & 0 deletions backend/include/jobpilot/core/utf8.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#pragma once

#include <string_view>

namespace jobpilot::core
{
// Strict UTF-8 check used at request boundaries before a value can reach
// storage or MySQL. Rejects overlong forms, surrogate code points, values
// above U+10FFFF, truncated sequences, and stray continuation bytes.
bool IsValidUtf8(std::string_view bytes) noexcept;
} // namespace jobpilot::core
134 changes: 134 additions & 0 deletions backend/src/core/utf8.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#include "jobpilot/core/utf8.h"

#include <cstddef>

namespace jobpilot::core
{
namespace
{
bool IsContinuation(unsigned char value) noexcept
{
return (value & 0xC0U) == 0x80U;
}
} // namespace

bool IsValidUtf8(std::string_view bytes) noexcept
{
std::size_t index = 0;

while (index < bytes.size())
{
const unsigned char first =
static_cast<unsigned char>(bytes[index]);

if (first <= 0x7FU)
{
++index;
continue;
}

if (first >= 0xC2U && first <= 0xDFU)
{
if (index + 1 >= bytes.size() ||
!IsContinuation(
static_cast<unsigned char>(bytes[index + 1])))
{
return false;
}

index += 2;
continue;
}

if (first >= 0xE0U && first <= 0xEFU)
{
if (index + 2 >= bytes.size())
{
return false;
}

const unsigned char second =
static_cast<unsigned char>(bytes[index + 1]);
const unsigned char third =
static_cast<unsigned char>(bytes[index + 2]);

if (!IsContinuation(third))
{
return false;
}

if (first == 0xE0U)
{
// E0 requires A0..BF to avoid an overlong 3-byte form.
if (second < 0xA0U || second > 0xBFU)
{
return false;
}
}
else if (first == 0xEDU)
{
// ED 80..9F only; A0..BF would encode a surrogate.
if (second < 0x80U || second > 0x9FU)
{
return false;
}
}
else if (!IsContinuation(second))
{
return false;
}

index += 3;
continue;
}

if (first >= 0xF0U && first <= 0xF4U)
{
if (index + 3 >= bytes.size())
{
return false;
}

const unsigned char second =
static_cast<unsigned char>(bytes[index + 1]);
const unsigned char third =
static_cast<unsigned char>(bytes[index + 2]);
const unsigned char fourth =
static_cast<unsigned char>(bytes[index + 3]);

if (!IsContinuation(third) || !IsContinuation(fourth))
{
return false;
}

if (first == 0xF0U)
{
// F0 requires 90..BF to avoid an overlong 4-byte form.
if (second < 0x90U || second > 0xBFU)
{
return false;
}
}
else if (first == 0xF4U)
{
// F4 80..8F only; 90..BF would exceed U+10FFFF.
if (second < 0x80U || second > 0x8FU)
{
return false;
}
}
else if (!IsContinuation(second))
{
return false;
}

index += 4;
continue;
}

return false;
}

return true;
}
} // namespace jobpilot::core
55 changes: 55 additions & 0 deletions backend/tests/test_utf8.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#include "jobpilot/core/utf8.h"

#include <string>

#include <gtest/gtest.h>

namespace jobpilot::core
{
namespace
{
TEST(Utf8Test, AcceptsValidAsciiAndUnicode)
{
EXPECT_TRUE(IsValidUtf8(""));
EXPECT_TRUE(IsValidUtf8("plain ASCII"));
EXPECT_TRUE(IsValidUtf8("\xE4\xB8\xAD\xE6\x96\x87"));
EXPECT_TRUE(IsValidUtf8("\xF0\x9F\x98\x80"));
EXPECT_TRUE(IsValidUtf8("\xEF\xBF\xBF"));
EXPECT_TRUE(IsValidUtf8("\xF4\x8F\xBF\xBF"));
}

TEST(Utf8Test, RejectsOverlongForms)
{
EXPECT_FALSE(IsValidUtf8(std::string("\xC0\xAF", 2)));
EXPECT_FALSE(IsValidUtf8(std::string("\xE0\x80\xAF", 3)));
EXPECT_FALSE(IsValidUtf8(std::string("\xF0\x80\x80\xAF", 4)));
}

TEST(Utf8Test, RejectsSurrogates)
{
EXPECT_FALSE(IsValidUtf8(std::string("\xED\xA0\x80", 3)));
EXPECT_FALSE(IsValidUtf8(std::string("\xED\xBF\xBF", 3)));
}

TEST(Utf8Test, RejectsTruncatedSequences)
{
EXPECT_FALSE(IsValidUtf8(std::string("\xE2", 1)));
EXPECT_FALSE(IsValidUtf8(std::string("\xE2\x82", 2)));
EXPECT_FALSE(IsValidUtf8(std::string("\xF0\x9F\x98", 3)));
}

TEST(Utf8Test, RejectsStrayContinuationAndInvalidLeads)
{
EXPECT_FALSE(IsValidUtf8(std::string("\x80", 1)));
EXPECT_FALSE(IsValidUtf8(std::string("\xBF", 1)));
EXPECT_FALSE(IsValidUtf8(std::string("\xC0", 1)));
EXPECT_FALSE(IsValidUtf8(std::string("\xFE", 1)));
EXPECT_FALSE(IsValidUtf8(std::string("\xFF", 1)));
}

TEST(Utf8Test, RejectsValuesAboveUnicodeMaximum)
{
EXPECT_FALSE(IsValidUtf8(std::string("\xF4\x90\x80\x80", 4)));
}
} // namespace
} // namespace jobpilot::core
26 changes: 22 additions & 4 deletions docs/04-api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ AI 接口均为异步:发起返回 `task_id`,客户端轮询 `GET /api/v1/ai
| 60001 | internal error | 服务端内部错误 |
| 60002 | database error | 数据库错误 |

**Field-aware parameter errors (2026-10-22).** `10001` may carry an additive
`data` object naming the rejected field, for example
`{"code":10001,"message":"invalid parameter","data":{"field":"title"}}`.
`code`, `message`, and the HTTP status are unchanged; existing clients may
ignore `data`. Raw JSON bodies that are not valid UTF-8 use `"field":"body"`.

---

## 3. 用户接口
Expand Down Expand Up @@ -221,8 +227,13 @@ POST /api/v1/resumes/upload

| 字段 | 说明 |
|---|---|
| file | 文件(PDF/TXT/MD) |
| title | 简历名称,如 "C++后端版" |
| file | 文件(PDF/TXT/MD),必填 |
| title | 简历名称,如 "C++后端版",必填;UTF-8 |

`title`/`filename` and the text body of `.txt`/`.md` files must be valid UTF-8.
Invalid input returns `400` with `code=10001` and, for these fields,
`data.field` set to `title` or `file`; no file or database row is written.
PDF bytes remain binary and are not UTF-8-validated.

```json
{
Expand Down Expand Up @@ -296,7 +307,8 @@ POST /api/v1/resumes/{id}/versions
```

`content` 为空或全空白 → `400`(`content` 为空串)/`text_status = "text_empty"`(全空白);
不带 `content` 键 → `400`。语义见 §4.1 的状态表。
不带 `content` 键 → `400`(`data.field=content`)。`content` 必须是合法 UTF-8;否则
返回 `400`/`10001` 且不写入任何版本。语义见 §4.1 的状态表。

### 4.4 简历版本列表

Expand Down Expand Up @@ -609,6 +621,8 @@ Authorization: Bearer <token>
`posted_within_days`,响应条目同样带 `freshness` 等 0012 字段。
- 推荐条目与 5.1 同形,因此也包含 `source_url` 与 0020 的 `apply_url`
(string|null,始终存在)。
- **新用户没有画像且没有技能时不是错误**:返回 `200` +
`data.list=[]`、`data.total=0`(并回显 `page`/`page_size`),不再返回 `10001`。

### 5.4 岗位内部导入

Expand Down Expand Up @@ -978,7 +992,8 @@ POST /api/v1/applications
```

`resume_version_id` 为必填项,且对应版本必须属于当前登录用户;`job_id`
必须对应启用中的岗位。重复投递返回 `40004`。
必须对应启用中的岗位。重复投递返回 `40004`。缺少或类型错误时返回
`400`/`10001`,并在 `data.field` 指明 `job_id` 或 `resume_version_id`。

### 6.2 投递列表

Expand Down Expand Up @@ -1065,6 +1080,9 @@ POST /api/v1/ai/match
}
```

`job_id` 与 `resume_id` 都是必填的正整数。缺少或类型错误时返回
`400`/`10001`,并在 `data.field` 指明 `job_id` 或 `resume_id`。

### 7.2 简历优化 / 面试题生成

```
Expand Down
8 changes: 4 additions & 4 deletions docs/quality/test-merge-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"removed": [],
"survivors": [],
"counts": {
"before": 312,
"after": 312,
"before": 321,
"after": 321,
"removed": 0,
"survivors": 0,
"candidates_evaluated": 66,
Expand Down Expand Up @@ -593,7 +593,7 @@
"evidence": "A-only assertion not present in B: EXPECT_EQ( service.BuildTaskInput(\"\", 1).code, ErrorCode::kInvalidParam)"
},
{
"removed": "JobRankingServiceTest.RequiresUsableProfileOrSkills",
"removed": "JobRankingServiceTest.ReturnsEmptyPageWithoutProfileOrSkills",
"survivor": "JobRankingServiceTest.SortsPaginatesAndReportsTruncation",
"binary": "test_job_ranking_service",
"file": "service/tests/test_job_ranking_service.cpp",
Expand All @@ -605,7 +605,7 @@
],
"rule_failure": "assertions_not_contained",
"reason": "the removed case asserts at least one target the survivor never asserts",
"evidence": "A-only assertion not present in B: EXPECT_EQ( service.RankJobs(1, {}, 1, 20).code, ErrorCode::kInvalidParam)"
"evidence": "A-only assertions not present in B: EXPECT_EQ(result.code, ErrorCode::kSuccess) and EXPECT_TRUE(result.value.list.empty())"
},
{
"removed": "MatchResultJsonTest.IsDeterministicForTheSameInput",
Expand Down
4 changes: 2 additions & 2 deletions docs/quality/test-overlap-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ in CI.
The audit classified every candidate pair by assertion target and concluded that
no test case is removable:

- 312 CTest cases before and after the audit; 0 removed.
- 321 CTest cases before and after the audit (312 at audit time; 9 added by 0028); 0 removed.
- 66 candidate pairs evaluated; 47 recorded only; 20-pair cap respected.
- No survivor is missing, no removed case is still live, and the live CTest list
still matches the map.

The complete historical report and its negative controls remain in Git history.

<!-- audit-counts: {"before":312,"after":312,"removed":0,"survivors":0,"candidates_evaluated":66,"candidate_pairs_unordered":50,"record_only":47,"cap":20} -->
<!-- audit-counts: {"before":321,"after":321,"removed":0,"survivors":0,"candidates_evaluated":66,"candidate_pairs_unordered":50,"record_only":47,"cap":20} -->
Loading
Loading