瀏覽代碼

fix(进件): 守卫依据订正(CR/LF 才成行) + fileBytes 自查 + null 契约补钉

F-6(Important,假依据):javadoc 原写「一个 CR / LF / tab 就能凭空多出一行头部」,
其中 **tab 那句是假的**。自己复现(真 FormHttpMessageConverter + 捕获线上字节,
两个度量:只认 CRLF 数行 / 认 CRLF|LF|CR 数行,外加减去分词假设的转义原文):
  CRLF   linesByCrlf 5(基线 4),X-Evil 起于行首 —— 成行;
  LF     linesByCrlf 4(**度量①看不到它**)、linesByAnyTerm 5,X-Evil 起于行首 —— 成行;
  裸CR    linesByCrlf 4、linesByAnyTerm 5 —— 按 MIME 的合法分隔符不成行,
         只有把裸 CR 也当分隔符的宽松分词才数得出「一行」(上游/中间件宽容度未实测);
  tab/NUL/DEL/VT/FF —— 两个度量都是 4、X-Evil 不在行首 ⇒ 实测**不成行**。
故把依据重写成三组、逐组标注强度:① CR/LF = 实测成行(成行的是 LF);② 其余 ISO 控制
字符 = 实测**不成行**,我们仍拒但那是**取舍**(统一宽规则 + 这些字符在名字里没有合法位置),
**不得**写成「实测的注入」;③ null = 头部没有「名字不存在」的表示法(实测静默改形状)。
测试未动:rejectsTabInFileName 等钉的是那条更宽的**规则**,规则没变,只有理由文案错了。

F-7:删掉「上游多半只回一句笼统的『img_file 必填 / 类型不对』」这种未实测推断,
只留我们这侧的可观测事实(部件静默退化),并显式写明上游侧**未实测**。
F-8:「不被签名覆盖」收窄为「不进入**我们这份**签名原文」,并写明上游拿哪份参数集重算未实测。

F-9(Minor):fileBytes == null 改用**我们自己的**检查(同一 IAE,消息点名 fileBytes)。
如实定性:这是**消息清晰度与对称性**的改动,**不是补泄漏** —— Spring 的 Assert 本来就会在
try 之外抛 IAE,位置与类型都对;之所以要自己的检查,是那条消息在几十个形参里认不出是谁。
@param fileBytes 里「在 try 之外、不会被包成 LeshouyingApiException」那句保留(它是对的)。

F-10(Minor):补测试钉住「整张 formFields 为 null 是允许的」这条零覆盖契约
(此前删掉 if (formFields != null) 不会有任何用例变红)。
F-11(Minor):@param formFields 原写「值两者都不限」是错的 —— signedPayload 剔 null,
而 multipart 的 body 由那份 payload 建 ⇒ 值为 null 时该字段**整条消失**(签名与报文一致地
消失,两者不打架,这是对的)。改成写明「null ⇒ 整条消失;空串 ⇒ 原样保留」,
并补测试同时钉住报文与「签名就是按这份部件集算的」。

验证:LeshouyingClientMultipartTest 20 条全绿(17 + 新 3);全量 451 通过(448 + 3)。
两向变异(各自重编译 435 主源文件,变异体确实生效):
  M-I 删掉自己的 fileBytes 检查 ⇒ 1 红,正是 rejectsNullFileBytes,且报出 Spring 的
      "Byte array must not be null" ⇒ 证明该用例的牙在**消息点名形参**,不在异常类型
      (只断言 IAE 是接不住这个改动的);
  M-J 无害:把该检查挪到入口最前(仍在任何出站之前)⇒ 20 条**仍绿**;
  M-K 改成拒绝 null map ⇒ 1 红,正是 allowsNullFormFieldsMap;
  M-L 在 multipart 腿给 null 值补空串部件(签名没有、报文有 ⇒ 打架)⇒ 1 红,
      正是 nullFieldValueDisappearsFromBothSignatureAndWire。
四次还原均实测:客户端 sha256 回到 cf1ece1e…
alphaH 3 周之前
父節點
當前提交
934afd7b53

+ 60 - 28
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java

@@ -132,29 +132,42 @@ public class LeshouyingClient {
      * 上游按它自己那份参数集重算,必然对不上,而失败只会表现为一句笼统的「签名错误」。
      * 本方法不开任何旁路:签名仍走 {@link LeshouyingSigner},与另外两条腿同一套规则。
      *
-     * <p>【前置条件:三个**会进部件头**的入口拒绝「头部无法表示」的输入 —— 这是**我们**的约束,
+     * <p>【前置条件:三个**会进部件头**的入口拒绝三类输入 —— 这是**我们**的约束,
      * 上游文档(page 196236572)**没有**提这件事】
-     * 两族输入、同一个 {@link IllegalArgumentException}、同样**在任何出站之前**(连实体都不构造,
-     * 一个字节都不出海):
+     * 三类输入、同一个 {@link IllegalArgumentException}、同样**在任何出站之前**(连实体都不构造,
+     * 一个字节都不出海);**但依据强度不同**,下面逐类标注(哪条是实测、哪条是取舍):
      * <ol>
-     *   <li>**ISO 控制字符**。实测(真实 {@code FormHttpMessageConverter} + 捕获线上字节):
-     *       {@code fileFieldName}、{@code formFields} 的**键**、{@code fileName} 三个值都会被原样
-     *       写进部件的 {@code Content-Disposition} 头,里面一个 CR / LF / tab 就能凭空多出一行头部
-     *       (线上一字不差长这样:{@code filename="a\r\nX-Evil: 1.jpg"} ⇒ {@code X-Evil: 1.jpg"}
-     *       成了部件头块里的独立一行)。</li>
-     *   <li>**null**。实测(同一套捕获装置,把守卫的 null 分支临时改回 {@code return} 跑出来的真实字节)
-     *       ——三种**都不是异常,而是静默改形状**,所以更要拒:
+     *   <li>**CR / LF:实测确实能在部件头块里造出一行**。{@code fileFieldName}、{@code formFields}
+     *       的**键**、{@code fileName} 三个值都会被原样写进部件的 {@code Content-Disposition}
+     *       (线上一字不差:{@code filename="a\r\nX-Evil: 1.jpg"} ⇒ {@code X-Evil: 1.jpg"}
+     *       成了部件头块里的独立一行)。**成行的是 LF**:{@code \r\n} 与裸 {@code \n} 实测都让注入串
+     *       落到行首 —— 但**要用两个度量**才看得全:{@code \r\n} 在「只认 CRLF」与「认 CRLF/LF/CR」
+     *       两个度量下都成行,裸 {@code \n} 只有后一个度量看得到(裸 LF 不是 {@code \r\n},
+     *       只按 {@code \r\n} 数行会把它数成「没注入」)。</li>
+     *   <li>**其余 ISO 控制字符(裸 CR、tab、NUL、DEL、VT、FF):实测并不成行** ——
+     *       它们打进 filename 后,注入串仍在**同一行内**,没有被任何分词度量算成独立的头部行。
+     *       我们**仍然拒**,但这是**取舍**、**不是**「实测的注入」:判据统一取
+     *       {@link Character#isISOControl} 一整族(宽规则比「只堵 CR/LF 两个字面量」更难绕过),
+     *       理由是这些字符在字段名/文件名里没有合法位置。
+     *       ★ 不要把这一族写成「实测能注入」—— 那是假的。
+     *       <br>(裸 CR 单独说明:按 MIME 的合法行分隔符 {@code \r\n} 判,它**不成行**;
+     *       只有把裸 CR 也当分隔符的宽松分词才数得出「一行」。上游/中间件会不会那样宽容**未实测**,
+     *       所以它既不写成「实测成行」,也不写成「实测不成行」。)</li>
+     *   <li>**null:部件头里没有「名字不存在」的表示法**。实测(同一套捕获装置,把守卫的 null 分支
+     *       临时改回 {@code return} 跑出来的真实字节)—— 三种**都不是异常,而是静默改形状**,所以更要拒:
      *       <ul>
      *         <li>{@code fileName == null} ⇒ 部件变成
      *             {@code Content-Disposition: form-data; name="img_file"}:<b>filename 段消失、
      *             Content-Type 从 {@code image/jpeg} 退化成 {@code application/octet-stream}</b>
-     *             —— 文件部件**静默退化成一个普通表单字段**,上游多半只回一句笼统的「img_file 必填 / 类型不对」,
-     *             排查方向指向参数而不是客户端;</li>
+     *             —— 文件部件**静默退化成一个普通表单字段**。(上游收到这种部件会怎么回**未实测**:
+     *             这里只写我们这侧的可观测事实,不写「上游会说 img_file 必填」这类推断。)</li>
      *         <li>{@code fileFieldName == null} ⇒ {@code Content-Disposition: form-data; filename="a.jpg"}
-     *             —— <b>连 name 段都没有</b>(部件无名字,上游无法归属到任何字段);</li>
+     *             —— <b>连 name 段都没有</b>(部件无名字)。</li>
      *         <li>{@code formFields} 的**键**为 null ⇒ {@code Content-Disposition: form-data}
-     *             (无名无 filename),且该部件**不进签名原文**(同一次实测里,签名与不含该键时逐字相同)
-     *             ⇒ 线上多出一个**既不具名、又不被签名覆盖**的部件。</li>
+     *             (无名无 filename),且该部件**不进我们这份签名原文**(同一次实测里,签名与不含该键时
+     *             逐字相同)⇒ 线上多出一个**既不具名、又不在我们签名原文里**的部件。
+     *             ★ 只说到「我们这份签名原文」为止:上游拿哪份参数集、怎么重算**未实测**,
+     *             不能由此推断「上游也不会把它算进去」。</li>
      *       </ul>
      *       {@code fileName} 尤其不是假想路径:它一般来自
      *       {@code MultipartFile.getOriginalFilename()},那个 API 的契约就是可空的。</li>
@@ -186,17 +199,24 @@ public class LeshouyingClient {
      *
      * @param path          以 / 开头的路径
      * @param formFields    表单里的**字符串**字段(不含 inst_no / sign,本方法自动补);
-     *                      文件**不**走这里。**键**不得为 null、不得含 ISO 控制字符
-     *                      (**值**两者都不限 —— 值走部件体,见上面第 3 段的位置判据)。
+     *                      文件**不**走这里。**键**不得为 null、不得含 ISO 控制字符(键进部件头,
+     *                      见上面第 3 段)。**值**走部件体、不受那条守卫管,但**值也不是「不限」**:
+     *                      值为 null ⇒ 该字段**整条消失**(签名原文与报文取自同一份 payload,
+     *                      所以是**一致地**消失、两者不打架)—— 我们这侧**根本不会发出这个字段**,
+     *                      上游看到的是一个缺字段的请求(上游具体怎么回**未实测**;但「必填字段缺失」
+     *                      与 null fileName 让文件部件退化属于同一类失败形态);**空串值则原样保留**。
      *                      整个 map 为 null 是允许的(等价于没有表单字段)
      * @param fileFieldName 文件部件的字段名(上游契约里是 {@code img_file});
      *                      不得为 null、不得含 ISO 控制字符
      * @param fileName      文件部件的文件名(multipart 的 filename),不是表单字段;
      *                      不得为 null、不得含 ISO 控制字符
-     * @param fileBytes     文件内容,**不得为 null**:{@code new ByteArrayResource(null)} 在
-     *                      {@code try} **之外**抛 {@link IllegalArgumentException},
-     *                      **不会**被包成 {@link LeshouyingApiException} —— 调用方若只 catch
-     *                      后者,这个错误会逃出它的错误通道(生产表现为 500)
+     * @param fileBytes     文件内容,**不得为 null**:由本方法**入口**的守卫拒绝(消息点名
+     *                      {@code fileBytes}),抛的 {@link IllegalArgumentException} 在
+     *                      {@code postForRawMultipart} 的 {@code try} **之外**,**不会**被包成
+     *                      {@link LeshouyingApiException} —— 调用方若只 catch 后者,
+     *                      这个错误会逃出它的错误通道(生产表现为 500)。
+     *                      这条守卫是**消息清晰度与对称性**的改动,**不是**补泄漏:
+     *                      它一个字节都不出海(理由写在方法体那条注释里)
      * @throws IllegalArgumentException 上面三个入口为 null 或含 ISO 控制字符,
      *                                  或 {@code fileBytes} 为 null
      */
@@ -212,20 +232,32 @@ public class LeshouyingClient {
                 requireHeaderSafe("formFields 的键", name);
             }
         }
+        // ★ 这条与上面三处**不同族**:fileBytes 不进部件头(它进部件体),所以这既不是防注入、
+        //   也不是补泄漏 —— `new ByteArrayResource(null)` 本来就会在 try 之外抛 IAE,位置与类型都对。
+        //   加它是为了两件事:① 消息**点名我们自己的形参**(Spring 的 Assert 只说
+        //   "Byte array must not be null",调用方从几十个形参里认不出是哪个);
+        //   ② 与三个头部入口的守卫**形态一致**(都在入口、都是 IllegalArgumentException、
+        //   都一个字节不出海)。**不要**把它写成「堵了一个泄漏」。
+        if (fileBytes == null) {
+            throw new IllegalArgumentException(
+                    "fileBytes 不得为 null:文件内容不能缺(multipart 的文件部件没有「内容不存在」的表示法)");
+        }
         return parser.parseApi(
                 postForRawMultipart(path, formFields, fileFieldName, fileName, fileBytes));
     }
 
     /**
-     * 部件头注入守卫:只查三个**会进部件头**的入口(见 {@link #postMultipart} 的 javadoc 第 3 段)。
-     * 拒**两族**「头部无法表示」的输入,判据一致、异常类型一致(同一个
-     * {@link IllegalArgumentException}):
+     * 部件头守卫:只查三个**会进部件头**的入口(见 {@link #postMultipart} 的 javadoc 第 3 段)。
+     * 拒三类输入,异常类型一致(同一个 {@link IllegalArgumentException}),**但依据强度不同**:
      * <ol>
-     *   <li>**ISO 控制字符**({@link Character#isISOControl},覆盖 CR / LF / tab / NUL / DEL)
-     *       —— 拼进 {@code Content-Disposition} 会凭空多出一行头部;</li>
-     *   <li>**null** —— 部件头里没有「名字不存在」的表示法,实测是**静默改形状**而非报错
-     *       (三个入口的线上读数见 {@link #postMultipart} 的 javadoc 第 3 段)。</li>
+     *   <li>**CR / LF** —— **实测会成行**:拼进 {@code Content-Disposition} 能凭空多出一行头部(成行的是 LF);</li>
+     *   <li>**其余 ISO 控制字符**(裸 CR、tab、NUL、DEL、VT、FF)—— **实测不成行**,
+     *       一并拒是**取舍**(统一的宽规则;这些字符在字段名/文件名里没有合法位置),
+     *       **不是**「实测的注入」;</li>
+     *   <li>**null** —— 部件头里没有「名字不存在」的表示法,实测是**静默改形状**而非报错。</li>
      * </ol>
+     * 三类的线上读数与依据强度逐条写在 {@link #postMultipart} 的 javadoc 第 3 段,
+     * **不要在这里复述成另一套说法**(两处措辞已因「tab 能注入」这个假依据错过一次)。
      *
      * <p>为什么必须在**组实体之前**:这些值一旦交给 {@code FormHttpMessageConverter} 就会被原样
      * 写进 {@code Content-Disposition},一个 CR/LF 就是一行伪造的头部;在入口抛 ⇒ 报文根本没生成,

+ 83 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientMultipartTest.java

@@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
@@ -321,6 +322,88 @@ class LeshouyingClientMultipartTest {
         assertRejectsNullHeaderBound("formFields 的键", FILE_FIELD, FILE_NAME, fields);
     }
 
+    /**
+     * ★ 钉住 `fileBytes == null` 是**我们自己的**检查(F-9)。
+     *
+     * <p>为什么断言里必须有「消息点名 `fileBytes`」这一条:**光断言 IAE 是接不住这个改动的** ——
+     * 去掉我们的检查,`new ByteArrayResource(null)` 照样抛 IAE(Spring 的 `Assert`,
+     * 消息是 "Byte array must not be null"),位置也仍在 `try` 之外。
+     * 所以这条用例钉的**不是**「会不会抛」(那本来就会抛),而是「消息认不认得出是哪个形参」
+     * 与「与另外三个入口形态一致」—— 这也正是这次改动的**唯一**正当性。
+     * 变异 M-I(删掉这条检查)打的就是这句断言。
+     *
+     * <p>顺带钉住「一个字节都不出海」:`verifyNoInteractions(restTemplate)`。
+     */
+    @Test
+    void postMultipart_rejectsNullFileBytes() {
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                        FILE_FIELD, FILE_NAME, null));
+
+        assertTrue(ex.getMessage().contains("fileBytes"),
+                "消息必须点名是哪个形参 —— Spring 的 Assert 只说 \"Byte array must not be null\","
+                        + "在几十个形参里认不出是谁,实际: " + ex.getMessage());
+        verifyNoInteractions(restTemplate);
+    }
+
+    /**
+     * ★ 钉住一条**零覆盖**的 javadoc 契约:**整张 `formFields` 为 null 是允许的**
+     * (等价于没有表单字段)。进件类接口确实可能只传文件 —— inst_no 与 sign 由本方法自动补。
+     *
+     * <p>此前没人钉过:契约只写在 `@param` 里,「删掉 `if (formFields != null)` 这个判断」
+     * 不会让任何既有用例变红(没人传过 null)。这条把它从**约定**变成**读数**。
+     */
+    @Test
+    void postMultipart_allowsNullFormFieldsMap() {
+        stubOk(OK_BODY);
+
+        Map<String, Object> resp = client.postMultipart(UPLOAD_PATH, null,
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        assertEquals("SUCCESS", resp.get("result_code"));
+        Map<String, Object> stringParts = stringPartsOf(capturedBody());
+        assertEquals(Set.of("inst_no", "sign"), stringParts.keySet(),
+                "map 为 null ⇒ 签名原文里只剩自动补的 inst_no + sign,实际: " + stringParts.keySet());
+        assertTrue(new LeshouyingSigner().verify(stringParts, props.getKey()),
+                "只含 inst_no + sign 的签名原文也必须能验回");
+        assertInstanceOf(Resource.class, capturedBody().getFirst(FILE_FIELD),
+                "map 为 null 不影响文件部件 —— 它照旧必须是带文件名的 Resource");
+    }
+
+    /**
+     * ★ 钉住「值为 null 的字段在**签名原文与报文里同时消失**」(F-11)。
+     *
+     * <p>为什么值得单钉一条:`@param formFields` 原先写「**值**两者都不限」,**这半句是错的** ——
+     * `signedPayload` 剔 null,而 multipart 的 body 是**从那份 payload 建的**
+     * ⇒ 值为 null 时该字段整条消失。两者**一致地**消失是对的(否则签名与报文会打架),
+     * 但调用方传 `img_name = null` 时**根本不会发出这个字段**,症状与 null `fileName`
+     * 让文件部件退化同类 —— 而这是从一个文档说「不管」的入口进来的坑。
+     *
+     * <p>两个面都断言:签名原文(mock 捕获的部件重算)与**线上字节**(真转换器)。
+     */
+    @Test
+    void postMultipart_nullFieldValueDisappearsFromBothSignatureAndWire() {
+        stubOk(OK_BODY);
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("img_name", IMG_NAME);
+        fields.put("remark", null);
+
+        client.postMultipart(UPLOAD_PATH, fields, FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        Map<String, Object> stringParts = stringPartsOf(capturedBody());
+        assertFalse(stringParts.containsKey("remark"),
+                "值为 null 的字段必须不出现在报文的字符串部件里 —— 报文由签名那份 payload 生成,"
+                        + "两边必须一致(否则上游按收到的部件重算会对不上),实际: " + stringParts.keySet());
+        assertTrue(new LeshouyingSigner().verify(stringParts, props.getKey()),
+                "报文里的字符串部件必须能验回那个 sign —— 这条钉住「签名确实是按这份部件集算的」");
+
+        String wire = captureWire(fields, FILE_NAME);
+        assertFalse(wire.contains("name=\"remark\""),
+                "报文里也必须没有这个字段(与签名原文一致),实际报文:\n" + wire);
+        assertTrue(wire.contains("name=\"img_name\""),
+                "同一次请求里非 null 的字段照旧上线(正对照),实际报文:\n" + wire);
+    }
+
     /**
      * 三个入口共用的拒绝断言:抛 IAE、消息**点名是哪个入口**、且**没有发生任何出站**
      * (守卫在组实体之前 ⇒ mock 的 {@code restTemplate} 一次都没被碰过)。