Apache Subversion 1.15 源码深度剖析-第 5 章 网络协议:svn:// 与 WebDAV/DeltaV

2 阅读1小时+

Apache Subversion 1.15 源码深度剖析-第 5 章 网络协议:svn:// 与 WebDAV/DeltaV

Apache Subversion 1.15.0 源码深度解析 · 第 5 章 目标仓库:subversion-1.15.0

0. 导读

Subversion 之所以能"一个仓库、多种访问方式",靠的是 RA(Repository Access,仓库访问)层这套抽象。客户端的 svn 命令行并不直接读写仓库,而是通过一个 svn_ra_plugin_t/svn_ra__vtable_t 风格的接口,把"检出、更新、提交、日志、锁定……"翻译成某一种具体网络协议上的报文。1.15.0 里同时存在几套 RA 实现,其中最核心、也是本章主角的两条链路是:

  1. svn://(以及隧道形态 svn+ssh://)—— 自定义的 ra_svn 二进制/文本协议。它由 subversion/libsvn_ra_svn/ 实现客户端与编解码,由 subversion/svnserve/ 实现服务端。协议的"官方说明书"不是 RFC,而是仓库里一个没有扩展名的文件:subversion/libsvn_ra_svn/protocol。它用 RFC 2234 的 ABNF 语法描述协议的报文文法,是本章最重要的一手资料。
  2. http:// / https:// —— 基于 HTTP/1.1 的 WebDAV/DeltaV 扩展。服务端是 subversion/mod_dav_svn/(一个 Apache httpd 模块,依赖 mod_dav),客户端是 subversion/libsvn_ra_serf/(基于 libserf)。它把 Subversion 的语义映射到 DAV 的"活动、基线、工作资源、版本资源"这套 DeltaV 模型上,并在 1.7/1.8 之后升级出更轻量的 HTTPv2。

两条链路共享同一个"编辑驱动器(editor drive)"模型:服务端把"某个版本到另一个版本之间发生了什么"表达成一串编辑器回调(add-dir/open-file/apply-textdelta/close-edit……),ra_svn 把它们编码成 svn 协议的 editor 命令,ra_serf 则把它们编码成 DeltaV 的 update-report XML 响应。理解了这两套"线上表示",就理解了 Subversion 网络的本质。

本章把两条链路放在一起讲,是因为它们在几个地方必须对照着看才讲得通:

  • 能力协商(capabilities):两边都有"我说我支持什么、你说你支持什么"的握手,缺口会导致降级或直接失败。
  • 认证:svn 协议用 SASL 风格的 auth-request,WebDAV 走 HTTP 的 Basic/Digest,但在"谁能读写哪些路径"这一层,两者又会汇聚到同一套 authz 子系统(svnserve 用 authz-db,httpd 用 AuthzSVNAccessFile)。
  • 提交:ra_svn 的 commit 命令与 mod_dav_svn 的 MKACTIVITY/CHECKOUT/PUT/MERGE 序列,是同一个"事务→新版本"过程在两种协议里的两种表达。

0.1 本章涉及的主要文件

svn 协议(svn://、svn+ssh://):libsvn_ra_svn/protocol(无扩展名,ABNF 文档,本章关于文法的所有论断都出自它)、client.c、marshal.c、internal_auth.c/cram.c/cyrus_auth.c/wrapped_sasl.c、editorp.c、svn_ra_svn.h;服务端 svnserve.c、serve.c、server.h、svnserve.conf.5。

WebDAV/DeltaV(http://、https://):mod_dav_svn.c(指令)、dav_svn.h(类型/REPORT 清单)、repos.c(URI/资源)、version.c(DeltaV 钩子/OPTIONS)、merge.c、activity.c、authz.c 与 mod_authz_svn.c(鉴权)、svn_dav.h/svn_dav_protocol.h(线上常量)、libsvn_ra_serf/(客户端,含 serf.c/options.c/update.c/commit.c/merge.c/replay.c/log.c/getlocations.c/getlocks.c/lock.c/property.c/inherited_props.c/blame.c/list.c 等)。

版本记录:CHANGES(1.15.0 章节,本章演进部分引用的 r 号均出自此文件)。

0.2 读完本章你将能够

  • 说清 svn 协议 4 种基本数据项(word/number/string/list)的精确文法,并手工构造出合法报文;
  • 逐步还原一次 svn:// 会话(3690 端口 → greeting 握手 → auth-request 认证 → repos-info → reparent/get-latest-rev),并列出 main/editor/report 三个命令集里的全部命令及其参数/响应原型;
  • 看懂 svnserve.conf 的每个选项、svnserve 的 fork/thread/single 三种连接模式,以及 1.15 的 SIGTERM/SIGINT 处理;
  • 说清 WebDAV 侧每个 HTTP 方法的用途、!svn/ 下全部私有资源类型(DeltaV 的 ver/his/wrk/act/vcc/bc/bln/wbl 与 HTTPv2 的 me/rev/rvr/txn/txr/vtxn/vtxr),以及 dav_svn__reports_list[] 的 12 个 REPORT 与 svn_dav_protocol.h 的自定义 XML 元素名;
  • 还原一次 WebDAV 提交的完整 HTTP 请求序列(方法、!svn/... URL、头部、体),理解 ra_serf 结构与 open_root 的 HTTPv2 分支;
  • 讲清 HTTP Basic/Digest、SSL 客户端证书、AuthzSVNAccessFile、SVNParentPath/SVNPath/DAVBasePath 与 autoversioning,并说出 1.15 六处网络层改动(r1929793、r1878084、r1926683、r1930808、r1926790、r1908547/r1934784)的实质。

本章尽量把每个结论都落到具体文件与行号上。凡是"协议长这样"的句子,你都能在 libsvn_ra_svn/protocol 或某一处 C 源码里找到出处;凡是"1.15 改了这个",你都能在 CHANGES 里找到对应的 r 号。


1. svn 协议:一手规范与基本数据项

1.1 规范文件本身

打开 libsvn_ra_svn/protocol,第一行就是它的自我定位:

This file documents version 2 of the svn protocol.

这个文件没有扩展名,也不是代码,而是一份人类可读的协议规范。它用 ABNF(RFC 2234)描述报文文法,用"原型(prototype)"记号描述每条命令的参数与响应。本章后面出现的所有原型写法(例如 reparent: ( url:string )),都直接抄自这个文件。

需要特别注意:这份文件描述的是协议版本 2。svn 协议早期还有版本 1,但 1.15 的服务端和客户端都只支持版本 2——这一点在握手阶段会体现得很明显(后文 2.1 节)。

1.2 四种基本数据项(item)

规范第 1 节"Syntactic structure"给出了协议的全部词法骨架,逐字抄录如下:

item   = word / number / string / list
word   = ALPHA *(ALPHA / DIGIT / "-") space
number = 1*DIGIT space
string = 1*DIGIT ":" *OCTET space
       ; digits give the byte count of the *OCTET portion
list   = "(" space *item ")" space
space  = 1*(SP / LF)

对这段 ABNF 逐条解释:

  1. item(数据项):协议里一切内容都是 item。一个 item 只能是四种形态之一:word、number、string、list。没有第五种。
  2. word(词):以一个大写或小写字母开头(ALPHA),后跟零个或多个字母、数字或连字符(-)。不允许下划线、不允许点、不允许数字开头。它用来表达"枚举值"——命令名、能力名、布尔字面量、节点类型等。词是大小写敏感的(规范原文:"Words are case-sensitive.")。例如 reparent、success、get-latest-rev、ANONYMOUS、CRAM-MD5(注意这里的连字符是合法字符,而 CRAM-MD5 里的 MD5 是数字+字母的组合,整体仍以 C 开头)。
  3. number(数):一个或多个十进制数字(1*DIGIT)。没有符号、没有小数点,即只能是非负整数。它用来表达版本号、长度以外的数值(版本号、布尔在协议里其实不是 number,见下)。例如 2、0、12345。
  4. string(串):先写"字节数",再写一个冒号 :,然后紧接着是精确那么多字节的原始内容。它用来承载"客户端或服务端真正关心的文本或二进制数据"——路径、URL、属性值、svndiff 分块等。关键点:字符串是长度前缀的,所以既能装 UTF-8 文本,也能装任意二进制(例如一个 svndiff 窗口),且不需要任何转义/加引号机制。
  5. list(列表):以 (,紧跟至少一个空白字符(space,即空格或换行),然后是零个或多个 item,最后是 ) 加至少一个空白。列表用于表达元组(tuple)、可选元组(optional tuple)或数组(array)。
  6. space(空白):一个或多个空格或换行(SP / LF)。每个 item 结束后都必须有空白——这是协议里最容易踩的坑:一个 number 后面如果没跟空白,解析器就无法判定它到底是"数完了"还是"后面还有数字"。

规范用下面这个例子展示四种元素同时出现的样子:

( word 22 6:string ( sublist ) )

逐字拆解这句:

  • 最外层是一个 list,以 ( 起、以 ) 收;
  • 里面第一个 item 是 word:word(5 个字母,是合法词);
  • 第二个 item 是 number:22;
  • 第三个 item 是 string:6:string——"6"是字节数,"string"恰好 6 个字节,然后是必需的空白;
  • 第四个 item 是 list:( sublist ),内含一个 word sublist;
  • 整个外层 list 以 ) 和行尾换行符收束(换行充当了外层 list 的终止空白)。

规范特意指出:"All items end with mandatory whitespace.(上面例子里,换行提供了外层列表的终止空白。)It is possible to parse an item without knowing its type in advance.(完全可以在不知道类型的前提下解析出一个 item。)"

为什么"不知道类型也能解析"?因为四种 item 的首字符就能唯一决定解析路径:

  • 首字符是数字 → 只可能是 number 或 string;继续读到冒号则为 string,读到空白则为 number。
  • 首字符是字母 → 一定是 word。
  • 首字符是 ( → 一定是 list。

这正是 marshal.c 中 read_item() 的实现逻辑(该函数从第 1298 行开始):它先拿到"第一个非空白字符",然后:

  if (svn_ctype_isdigit(c))
    {                       /* 数字或字符串:先把数字部分读出来 */
      val = c - '0';
      while (1) { ...; if (!svn_ctype_isdigit(c)) break; val = val * 10 + (c - '0'); }
      if (c == ':') { SVN_ERR(read_string(conn, pool, item, val)); ... }
      else          { item->kind = SVN_RA_SVN_NUMBER; item->u.number = val; }
    }
  else if (svn_ctype_isalpha(c)) { /* 是 word */ }
  else if (c == '(')             { /* 是 list,递归读入元素直到 ')' */ }

  /* 保险:读完后光标必须落在空白上,否则判为报文畸形 */
  if (!svn_iswhitespace(c))
    return svn_error_create(SVN_ERR_RA_SVN_MALFORMED_DATA, NULL,
                            _("Malformed network data"));

即:如果一个 item 读完后"光标"落在非空白字符上,就判为报文畸形。这条检查对应规范里"每个 item 都必须以空白结尾"的强制要求。

1.3 列表的三种用法:元组 / 可选元组 / 数组

规范对 list 的语义做了重要区分:

  • 元组(tuple):期望包含固定数量的 item,且各 item 类型通常不同。比如 ( rev:number ) 表示"就一个 number"。
  • 可选元组(optional tuple):包含零个或固定数量的 item。注意这里"optional"指的是"内容可有可无",而不是"这个列表本身可以缺席"。空元组要写成 ( )——括号必须存在,里面留空。
  • 数组(array):包含零个或多个同类型 item。

1.4 原型记号(怎么读这份规范)

规范为了便于描述,定义了一套"原型(prototype)"写作约定:

example: ( literal ( data:string ... ) )

规则如下:

  • 不带冒号的单词(如 literal)表示"一个字面量 word"——即报文里就该出现这个词。
  • name:type 表示"一个名为 name、类型为 type 的参数"。
  • 类型可以是 word、number、string、list,或另一个原型名。
  • 圆括号表示元组;如果括号里含有省略号 ...,则表示"数组"——即"省略号前那个原型可以重复零次或多次"。例如 ( data:string ... ) 表示"零个或多个 string"。
  • ? 用于标注"元组允许在此处结束",即它之前的部分是可选的。规范举例:
example: ( fixed:string ? opt1:number opt2:string ? opt3:number )

这个元组可以只含 1 个 item(在第一个 ? 处结束)、或 3 个、或 4 个乃至更多(在第二个 ? 处结束或继续读)。

  • 方括号 [ ... ] 是可选元组的简写,等价于"圆括号 + 一个前导 ?"。规范举例说明这两种写法等价:
example: ( literal (? rev:number ) ( data:string ... ) )

; 更紧凑的等价写法:
example: ( literal [ rev:number ] ( data:string ... ) )
  • | 表示"多选一"(alternatives)。例如:
example: ( first-kind rev:number )
       | second-kind

1.5 命令响应(command-response)与错误(error)

规范定义了一个被复用的原型,叫"命令响应",用来统一表达一次操作的成败:

  command-response: ( success params:list )
                  | ( failure ( err:error ... ) )
  error: ( apr-err:number message:string file:string line:number )

含义:

  • 成功时是 ( success <一个列表> ),成功参数的解读依赖于具体命令的上下文;
  • 失败时是 ( failure (<错误项>...) ),其中每个错误项是一个元组 ( apr-err:number message:string file:string line:number ):APR 错误码、错误消息、出错文件名、出错行号。

这里的 apr-err 是 APR 错误码(number),file/line 用来在客户端重建出错位置。1.15 的 marshal.c 里 svn_ra_svn__write_cmd_failure()(第 2761 行起)用格式串 "nccn" 逐条把 svn_error_t 错误链序列化出去(apr_err、msg、file、line)。注意 message 在协议里"本应可选",但出于兼容性,实现把"没有消息"编码成空串 "",客户端读到时再还原成 NULL。反向解析在 svn_ra_svn__handle_failure_status()(第 1807 行起):它从后往前重建错误链,并跳过 SVN_ERR_RA_SVN_CMD_ERR 这类只应存在于服务端的包装层(if ((apr_status_t)apr_err != SVN_ERR_RA_SVN_CMD_ERR) { err = svn_error_create(...); ... })。

1.6 URL 与仓库路径

规范原文:"URLs and repository paths are represented as strings. They should be in canonical form when sent over the protocol."——URL 和仓库路径都当作 string 传输,且发送时应当是规范形式(canonical form)。同时又要求:"作为输入校验,实现应当在需要规范形式时总是对自己收到的路径做规范化。"也就是说,发送方保证规范,接收方仍要自保。

这一点在服务端 serve.c 里能看到对 client_url 的规范校验:收到客户端 URL 后立刻调用 svn_uri_canonicalize_safe(...)(serve.c 第 4290 行附近),把客户端送来的 URL 变成规范形式再用于后续拼接与查找。

1.7 一条手工构造的报文

现在把上面的知识拼起来:reparent(把会话"重新挂"到另一个 URL)的原型是 reparent: params ( url:string ) / response: ( )。以 url = svn://localhost/repo 为例,报文是:

( reparent ( 20:svn://localhost/repo ) )

逐项标注:外层 ( 表示整个命令是一个 list;reparent 是命令名(word);( 开始 params 子 list;20:svn://localhost/repo 是一个 string——svn://localhost/repo 恰好 20 字节(svn://=6、localhost=9、/=1、repo=4),所以长度前缀必须是 20;) 关闭 params list;) + 换行关闭命令 list。注意:长度前缀写错一个字节,整条报文都会错位——协议靠字节数而非分隔符界定边界,所以发送方必须数清字节数(上面若写成 22: 就是错的)。

服务端读这条命令时用 svn_ra_svn__handle_command()(marshal.c 第 1907 行):svn_ra_svn__read_tuple(conn, pool, "wl", &cmdname, &params)——"wl" 表示"第一个 item 读成 word(命令名),第二个读成 list(参数表)",随后按 cmdname 在 cmd_hash 查表命中就调用 handler。

1.8 编码侧的实现细节

marshal.c 里负责"写"的函数,逐一对应前面四种 item:

  • 写 number:svn_ra_svn__write_number() 调用 write_number(conn, pool, number, ' '),用 svn__ui64toa 转成十进制,后面补一个空格(第 664 行起)。
  • 写 string:svn_ra_svn__write_ncstring() 走一条"快路径"——直接在缓冲区里写"长度 + ':' + 内容 + ' '";长度小于 10 时用单字符长度((char)(len + '0')),否则用十进制(第 697 行 write_ncstring_quick)。
  • 写 word:svn_ra_svn__write_word() 写词本身再补一个空格(第 774 行)。
  • 写 list:svn_ra_svn__start_list() 写 "( "(左括号 + 空格),svn_ra_svn__end_list() 写 ") "(右括号 + 空格)(第 829、844 行)。
  • 写布尔:直接用字面量 "true " / "false "(第 785 行 svn_ra_svn__write_boolean)——这印证了规范里 bool: true|false 是 word 而非 number。

1.9 从函数名到报文:svn_ra_svn__write_tuple 的格式串

客户端/服务端不逐字符拼报文,而是用一个格式串描述元组结构,交给 svn_ra_svn__write_tuple()(marshal.c @1197,核心 vwrite_tuple() @1145)自动编码。格式串字符:c=C 字符串→string、s=svn_string_t*→string、w=词→word、r=版本号→number、n=无符号数→number、b=布尔→word(true/false)、?=其后参数变可选、(/)=开/闭子列表、!=本元组不写外层括号(用于拼接外层结构)。读侧 vparse_tuple()(@1607)语义一致,另有 l(list)、B、3(tristate)。

例:serve.c 第 4261 行回 greeting 的格式串 "nn()(wwwwwwwwwww)",对照规范原型 greeting: ( minver:number maxver:number mechs:list ( cap:word ... ) ) 可逐字对上:nn = minver/maxver(都 2)、() = 空的 mechs(版本 2 不再使用它)、(w…) = 11 个 word 的 cap 数组。write_cmd_response() 会在外层自动补上 ( success,于是线上是:

( success ( 2 2 ( ) ( edit-pipeline absent-entries commit-revprops depth log-revprops atomic-revprops partial-replay inherited-props ephemeral-txnprops file-revs-reverse list ) ) )

1.10 限制与可扩展性

规范第 4 节给出三种扩展方式(按推荐程度递减):① 往任意元组尾部追加 item——老实现会忽略多余 item(规范 1 节亦强调"实现必须把含额外元素的 list 视为匹配原型的元组,额外元素必须被忽略"),这是最推荐的方式;② 在连接初始化时声明命名扩展(即 capabilities);③ 提升协议版本号,两端再各自挑选可接受的版本区间。

规范 4.1 节给出一条硬限制:"The current implementation limits the length of a word to 31 characters."——但实现(marshal.c)比规范更严:第 66 行 #define MAX_WORD_LENGTH 25,注释说"当前最长的词约 16 字符,留余量给未来更长的能力名/命令名";读词若到第 25 字符仍未结束就报 "Word is too long"(第 1383 行)。

规范 4.2 节示范了"扩展既有命令"的做法:先让元组"可在当前位置结束"(加 ?),再接上新的、可能可选的 item——例如 diff 从 ( [ rev:number ] target:string recurse:bool ignore-ancestry:bool url:string ) 扩展成末尾多一个 ? text-deltas:bool。对本来就可选的参数则必须始终发送空元组占位,好让未来还能继续往括号里加东西——set-path 就是这么把 lock-token 塞进 [ lock-token:string ] 的:即使没有 lock-token,客户端也要发一个 ( )。

这些"向后兼容纪律"正是 1.15 客户端仍能应对"1.8.0–1.8.8 服务端 bug"的原因——get-file 里那句"总是显式发送 want-iprops 为 false"的 workaround(marshal.c 第 2450 行)就是活例:

  /* Always send the, nominally optional, want-iprops as "false" to
     workaround a bug in svnserve 1.8.0-1.8.8 that causes the server
     to see "true" if it is omitted. */
  SVN_ERR(writebuf_write_literal(conn, pool, " false ) ) "));

2. 连接建立、版本协商与认证

2.1 默认端口与 greeting

规范第 2 节开宗明义:"By default, the client connects to the server on port 3690."——默认端口 3690。这个数字在 include/svn_ra_svn.h 里是一个宏:

#define SVN_RA_SVN_PORT 3690

连接建立后,服务端先开口,发出 greeting,其原型是:

  greeting: ( minver:number maxver:number mechs:list ( cap:word ... ) )

字段含义:

  • minver / maxver:服务端支持的最小/最大协议版本;
  • mechs:SASL 机制列表——规范注明它"出于历史原因存在,客户端会忽略它";
  • cap:服务端能力(server capabilities,见 2.4)。

规范接着规定客户端的应对:"If the client does not support a protocol version within the specified range, it closes the connection."——若客户端不支持这个区间里的任何版本,就关闭连接。否则客户端回一个 response:

  response: ( version:number ( cap:word ... ) url:string
              ? ra-client:string ( ? client:string ) )

字段含义:

  • version:客户端选定的协议版本;
  • cap:客户端能力;
  • url:客户端正在访问的 URL;
  • ra-client:标识 RA 实现的字符串,如 "SVN/1.6.0" 或 "SVNKit 1.1.4";
  • client:svn_ra_callbacks2_t.get_client_string 的返回值,该回调可能未实现,因此可选。

服务端收到这个 response 后,发一个认证请求(auth-request)。

规范还给出一个关键约束:服务端在收到 greeting 的回应后才发 auth-request,并且"repos-info 是在认证交换成功之后才发的"。也就是说,一条完整的新连接会话,顺序固定为:

服务端 → greeting
客户端 → response
服务端 → auth-request
客户端 → auth-response
(服务端 ↔ 客户端 多轮 challenge/step/success/failure)
服务端 → repos-info
客户端 → 进入主命令集

2.2 服务端如何构造 greeting(版本 2)

在 svnserve/serve.c 的 serve()(从第 4221 行左右开始)里,第一件事就是发 greeting。注意服务端这里根据是否有压缩发不同的能力列表:

  /* Send greeting.  We don't support version 1 any more, so we can
   * send an empty mechlist. */
  if (params->compression_level > 0)
    SVN_ERR(svn_ra_svn__write_cmd_response(conn, scratch_pool,
             "nn()(wwwwwwwwwwwww)",   /* 13 个能力,含 svndiff1/accepts-svndiff2 */
             (apr_uint64_t) 2, (apr_uint64_t) 2,
             SVN_RA_SVN_CAP_EDIT_PIPELINE, SVN_RA_SVN_CAP_SVNDIFF1,
             SVN_RA_SVN_CAP_SVNDIFF2_ACCEPTED, /* ……其余从略…… */
             SVN_RA_SVN_CAP_LIST));
  else
    SVN_ERR(svn_ra_svn__write_cmd_response(conn, scratch_pool,
             "nn()(wwwwwwwwwww)",     /* 11 个能力,少了 svndiff1/accepts-svndiff2 */
             ...));

为什么 minver = maxver = 2? 因为注释写得很直白:"We don't support version 1 any more, so we can send an empty mechlist."——协议版本 1 早已废弃,服务端只支持 2,所以最小与最大都是 2,mechs 列表发空。

为什么有/无压缩的能力列表不同? 因为 svndiff1(带 zlib 压缩的 svndiff 版本 1)与 accepts-svndiff2(接受 svndiff2 增量)只有在允许压缩时才有意义:若 --compression 0(或 SVNCompressionLevel 0),服务端干脆不宣称支持这两种 svndiff。

服务端随后读客户端 response:

  SVN_ERR(svn_ra_svn__read_tuple(conn, scratch_pool, "nlc?c(?c)",
                                 &ver, &caplist, &client_url,
                                 &ra_client_string,
                                 &client_string));
  if (ver != 2)
    return svn_error_createf(SVN_ERR_RA_SVN_BAD_VERSION, NULL,
                             "Unsupported ra_svn protocol version"
                             " %"APR_UINT64_T_FMT
                             " (supported versions: [2])", ver);

格式串 "nlc?c(?c)" 对应 response 原型:number(版本)、list(能力)、string(url),然后可选的 c(ra-client)与可选元组 (?c)(client)。若客户端选的版本不是 2,直接以 SVN_ERR_RA_SVN_BAD_VERSION 报"只支持 [2]"。

2.3 客户端侧:open_session() 与隧道

客户端的对应实现在 libsvn_ra_svn/client.c 的 open_session()(第 615 行起)。它读 greeting:

  /* Read server's greeting. */
  SVN_ERR(svn_ra_svn__read_cmd_response(conn, pool, "nnll", &minver, &maxver,
                                        &mechlist, &server_caplist));

  /* We support protocol version 2. */
  if (minver > 2)
    return svn_error_createf(SVN_ERR_RA_SVN_BAD_VERSION, NULL,
                             _("Server requires minimum version %d"),
                             (int) minver);
  if (maxver < 2)
    return svn_error_createf(SVN_ERR_RA_SVN_BAD_VERSION, NULL,
                             _("Server only supports versions up to %d"),
                             (int) maxver);
  SVN_ERR(svn_ra_svn__set_capabilities(conn, server_caplist));

  /* All released versions of Subversion support edit-pipeline,
   * so we do not support servers that do not. */
  if (! svn_ra_svn_has_capability(conn, SVN_RA_SVN_CAP_EDIT_PIPELINE))
    return svn_error_create(SVN_ERR_RA_SVN_BAD_VERSION, NULL,
                            _("Server does not support edit pipelining"));

这里有三道关,值得逐条记住——它们就是本章"边界与演进"的第一类降级/失败点:

  1. minver > 2 → 服务端要求的版本比客户端高 → 失败("Server requires minimum version N")。
  2. maxver < 2 → 服务端最高只到 <2 → 失败("Server only supports versions up to N")。
  3. 通过了版本区间,但服务端没宣称 edit-pipeline → 失败("Server does not support edit pipelining")。注释解释了原因:自 1.0 以来所有正式发布的版本都宣称 edit-pipeline,1.5 起两端都强制要求对方宣称它(规范 2.1 节亦如此说)。所以缺它意味着对端必然是个"非正常"实现。

客户端接着回 response:

  SVN_ERR(svn_ra_svn__write_tuple(conn, pool, "n(wwwwwww)cc(?c)",
                                  (apr_uint64_t) 2,
                                  SVN_RA_SVN_CAP_EDIT_PIPELINE,
                                  SVN_RA_SVN_CAP_SVNDIFF1,
                                  SVN_RA_SVN_CAP_SVNDIFF2_ACCEPTED,
                                  SVN_RA_SVN_CAP_ABSENT_ENTRIES,
                                  SVN_RA_SVN_CAP_DEPTH,
                                  SVN_RA_SVN_CAP_MERGEINFO,
                                  SVN_RA_SVN_CAP_LOG_REVPROPS,
                                  url,
                                  SVN_RA_SVN__DEFAULT_USERAGENT,
                                  client_string));
  SVN_ERR(handle_auth_request(sess, pool));

可见客户端宣称的 7 个能力依次是:edit-pipeline、svndiff1、accepts-svndiff2、absent-entries、depth、mergeinfo、log-revprops。其中 SVN_RA_SVN__DEFAULT_USERAGENT 定义在 libsvn_ra_svn/ra_svn.h 第 62 行:

#define SVN_RA_SVN__DEFAULT_USERAGENT  "SVN/" SVN_VER_NUMBER\
                                      " (" SVN_BUILD_TARGET ")" ...

在 1.15.0 下它展开成类似 SVN/1.15.0 (x86_64-... ) 的字符串。

2.3.1 svn:// 与 svn+ssh:// 的区别

规范只讲"线上一串字节",不关心字节是怎么送到的。传输方式由 URL 的 scheme 决定,这一点在客户端的 parse_tunnel()(client.c 第 95 行起)里实现:

static void parse_tunnel(const char *url, const char **tunnel,
                         apr_pool_t *pool)
{
  *tunnel = NULL;
  if (strncasecmp(url, "svn", 3) != 0)
    return;
  url += 3;
  /* Get the tunnel specification, if any. */
  if (*url == '+')
    {
      const char *p;
      url++;
      p = strchr(url, ':');
      if (!p) return;
      *tunnel = apr_pstrmemdup(pool, url, p - url);
    }
}

即:URL 以 svn 开头后,如果紧跟 +,则 + 与 : 之间的部分是"隧道名(tunnel)"。于是:

  • svn://host/path → 隧道名为空,走裸 TCP,直连 3690 端口;
  • svn+ssh://host/path → 隧道名为 ssh,客户端不直连 3690,而是启动一个外部进程(默认是 ssh),把协议流通过这个进程的 stdin/stdout 转发。

open_session() 里两者分道扬镳(第 659–702 行):隧道分支设 sess->realm_prefix = "<svn+%s://%s:%d>" 并按 tunnel_argv 调 make_tunnel()(没有时用 callbacks->open_tunnel_func,如 Java 绑定);非隧道分支设 realm_prefix = "<svn://host:port>",并 make_connection(hostname, uri->port ? uri->port : SVN_RA_SVN_PORT, ...) 后 svn_ra_svn_create_conn5(sock, NULL, NULL, ...)。

注意 realm_prefix 的差别会影响凭据缓存作用域:svn:// 的缓存键前缀是 <svn://host:port>,隧道是 <svn+ssh://host:port>——这解释了"同一仓库用 svn:// 和 svn+ssh:// 访问会分别要求密码"。

隧道命令默认值在 find_tunnel_agent()(client.c 第 376 行起):若用户未在 [tunnels] 段覆盖,内置的 ssh 方案是 val = "$SVN_SSH ssh -q --";——$SVN_SSH 允许用环境变量覆盖 ssh 命令,-q 抑制 ssh 的杂散输出(make_tunnel 出错时的提示语正与此有关:"To better debug SSH connection problems, remove the -q option from 'ssh'...")。随后它把固定参数追加到命令行末尾:argv[n++] = hostinfo; argv[n++] = "svnserve"; argv[n++] = "-t";,即客户端实际执行 ssh -q -- <host> svnserve -t。

在远端,svnserve -t 进入 tunnel 模式:不监听端口,直接在 stdin/stdout 上跑协议循环(svnserve.c 中 run_mode == run_mode_tunnel 分支,第 1109 行起,用 svn_stream_for_stdin2/svn_stream_for_stdout 建立连接再 serve())。

2.3.2 --tunnel-user 与 EXTERNAL 认证

隧道模式下服务端如何知道"客户端是谁"?靠 EXTERNAL 机制:服务端信任 SSH 已确定登录用户,把它直接当作认证身份。若 SSH 登录名与期望的仓库用户名不一致,可用 --tunnel-user——它的帮助文本(svnserve.c 第 411 行)是 N_("tunnel username (default is current uid's name)\n [mode: tunnel]"),且只在 tunnel 模式合法,否则报 SVN_ERR_CL_ARG_PARSING_ERROR "Option --tunnel-user is only valid in tunnel mode"(第 1103 行)。

服务端的 EXTERNAL 处理在 auth()(serve.c 第 651 行起):若 auth_access >= required、client_info->tunnel_user 非空且 mech == "EXTERNAL",则——客户端在 mecharg 里报了用户名时它必须等于 tunnel_user,否则回 ( failure "Requested username does not match" )(用 "w(c)" 格式串写出);否则把 client_info->user = tunnel_user 并回 ( success )("w()")。若客户端未报,就直接采用 tunnel_user(默认当前 uid 的名字)。

2.4 能力(capabilities)全表

规范 2.1 节把当时定义的能力列了个表,标注 [S] 表示服务端能力、[C] 表示客户端能力,[CS] 双方都要。逐条抄录并解释:

能力名标记含义
edit-pipeline[CS]自 1.0 起每个正式版本都宣称;1.5 起双方强制要求对方宣称。
svndiff1[CS]若双方都支持 svndiff 版本 1,则线上用 svndiff1 取代 svndiff0。
accepts-svndiff2[CS]声称"我能接受 svndiff2 增量";增量发送方(编辑器驱动方)可以用接收方声明可接受的任何 svndiff 版本发送。
absent-entries[CS]若对端宣称此能力,它将接受 absent-dir/absent-file 编辑器命令。
commit-revprops[S]服务端支持 commit 命令的 rev-props 参数。
mergeinfo[S]服务端支持 get-mergeinfo 命令。
depth[S]服务端理解"请求的操作深度"与"每路径的环境深度"。
atomic-revprops[S]服务端支持 change-rev-prop2 命令。
inherited-props[S]服务端支持通过 get-dir/get-file 取继承属性,也支持 get-iprops 命令。
list[S]服务端支持 list 命令。

include/svn_ra_svn.h 里另有一批宏,是规范表之外的(同样通过能力列表在线上传递):SVN_RA_SVN_CAP_EDIT_PIPELINE="edit-pipeline"、SVN_RA_SVN_CAP_SVNDIFF1="svndiff1"、SVN_RA_SVN_CAP_SVNDIFF2_ACCEPTED="accepts-svndiff2"、SVN_RA_SVN_CAP_ABSENT_ENTRIES="absent-entries"、SVN_RA_SVN_CAP_COMMIT_REVPROPS="commit-revprops"、SVN_RA_SVN_CAP_MERGEINFO="mergeinfo"、SVN_RA_SVN_CAP_DEPTH="depth"、SVN_RA_SVN_CAP_LOG_REVPROPS="log-revprops"、SVN_RA_SVN_CAP_PARTIAL_REPLAY="partial-replay"、SVN_RA_SVN_CAP_ATOMIC_REVPROPS="atomic-revprops"、SVN_RA_SVN_CAP_INHERITED_PROPS="inherited-props"、SVN_RA_SVN_CAP_EPHEMERAL_TXNPROPS="ephemeral-txnprops"、SVN_RA_SVN_CAP_GET_FILE_REVS_REVERSE="file-revs-reverse"、SVN_RA_SVN_CAP_LIST="list"。

其中 log-revprops、partial-replay、ephemeral-txnprops、file-revs-reverse 是规范表未收录但实现已在用的能力。能力检查在实现里就是一次哈希查表(marshal.c 第 300 行):svn_ra_svn_has_capability(conn, cap) 即 svn_hash_gets(conn->capabilities, cap) != NULL。而设置能力的 svn_ra_svn__set_capabilities()(第 248 行)会检查每个条目必须是 word,否则报 "Capability entry is not a word"。

2.5 认证:auth-request / auth-response / challenge

服务端在收到 response 后发认证请求,原型是:

  auth-request: ( ( mech:word ... ) realm:string )
  • mech:服务端支持的 SASL 机制列表;
  • realm:类似 [RFC 2617] 里的 HTTP 认证 realm,用来指示"服务端希望在哪个保护空间里认证"。规范特别说明:"If the mechanism list is empty, then no authentication is required and no further action takes place as part of the authentication challenge."——空机制列表意味着不需要认证,直接进入下一阶段。

若机制列表非空,客户端回:

  auth-response: ( mech:word [ token:string ] )
  • mech:选定的 SASL 机制;
  • token:若存在,是该认证交换的"初始响应(initial response)";
  • 客户端可以指定一个空机制来拒绝认证(( ))。

之后服务端发一系列 challenge,每个匹配:

  challenge: ( step ( token:string ) )
           | ( failure ( message:string ) )
           | ( success [ token:string ] )

语义:

  • 首词是 step → token 由认证机制解释,客户端把响应 token 作为 string 传回服务端,服务端继续发下一个 challenge;客户端若要中止,直接关连接即可;
  • 首词是 success → 认证成功;若带 token,则由机制解释但不需响应;
  • 首词是 failure → 认证失败;客户端可以放弃,也可以再发一个 auth-response 重新开始。

规范还提到 RFC 2222(SASL)要求协议 profile 为 GSSAPI 机制定义一个服务名,本协议的服务名是 svn。

2.5.1 1.15 实际提供的机制

服务端 send_mechs()(serve.c 第 582 行起)决定向客户端宣称哪些机制:

static svn_error_t *send_mechs(svn_ra_svn_conn_t *conn, apr_pool_t *pool,
                               server_baton_t *b, enum access_type required,
                               svn_boolean_t needs_username)
{
  if (!needs_username && b->repository->anon_access >= required)
    SVN_ERR(svn_ra_svn__write_word(conn, pool, "ANONYMOUS"));
  if (b->client_info->tunnel_user && b->repository->auth_access >= required)
    SVN_ERR(svn_ra_svn__write_word(conn, pool, "EXTERNAL"));
  if (b->repository->pwdb && b->repository->auth_access >= required)
    SVN_ERR(svn_ra_svn__write_word(conn, pool, "CRAM-MD5"));
  return SVN_NO_ERROR;
}

所以内建(非 SASL)实现只提供三种机制:

  1. ANONYMOUS:无需用户名密码。条件:!needs_username 且匿名访问级别 ≥ 当前所需级别。
  2. EXTERNAL:仅当存在 tunnel_user(即隧道模式且配了 --tunnel-user)时提供。
  3. CRAM-MD5:仅当仓库配置了 pwdb(即 svnserve.conf 里指定了 password-db)时提供。

客户端 svn_ra_svn__do_internal_auth()(internal_auth.c 第 70 行起)的选择顺序正是这三个:

  if (sess->is_tunneled && svn_ra_svn__find_mech(mechlist, "EXTERNAL"))
    {
      SVN_ERR(svn_ra_svn__auth_response(conn, pool, "EXTERNAL", ""));
      return read_success(conn, pool);
    }
  else if (svn_ra_svn__find_mech(mechlist, "ANONYMOUS"))
    {
      SVN_ERR(svn_ra_svn__auth_response(conn, pool, "ANONYMOUS", ""));
      return read_success(conn, pool);
    }
  else if (svn_ra_svn__find_mech(mechlist, "CRAM-MD5"))
    {
      ... /* 取用户名/密码,做 CRAM-MD5 交换 */
    }
  else
    return svn_error_create(SVN_ERR_RA_SVN_NO_MECHANISMS, NULL, NULL);

注意 svn_ra_svn__do_internal_auth 依赖于 SVN_HAVE_SASL 的编译开关:client.c 顶部有

#ifdef SVN_HAVE_SASL
#define DO_AUTH svn_ra_svn__do_cyrus_auth
#else
#define DO_AUTH svn_ra_svn__do_internal_auth
#endif

也就是说:编译时若启用 Cyrus SASL,认证就交给 Cyrus SASL,内建实现只在没有 SASL 时兜底。Cyrus SASL 可以实现更丰富的机制(包括 PLAIN、LOGIN、DIGEST-MD5、GSSAPI 等),但那条路径的机制名由 SASL 库决定,而内建实现自身永远只发 ANONYMOUS/EXTERNAL/CRAM-MD5。因此在本章语境里,所谓"PLAIN 机制"只可能来自 Cyrus SASL 路径,不是内建的全新机制。

2.5.2 CRAM-MD5 的交换细节

CRAM-MD5 是一种挑战-响应机制:服务端发一个 challenge(nonce),客户端用"用户名 + HMAC-MD5(密码, challenge)"作为响应,密码本身不上线。cram.c 是"极简、独立"的 CRAM-MD5 实现,其核心 compute_digest()(第 82 行)就是标准的 HMAC-MD5:

  /* Compute MD5(secret XOR opad, MD5(secret XOR ipad, challenge)),
   * where ipad is a string of 0x36 and opad is a string of 0x5c. */

服务端失败时通过 fail() 发 ( failure ( <msg> ) )(第 116 行):

  SVN_ERR(svn_ra_svn__write_tuple(conn, pool, "w(c)", "failure", msg));

客户端 read_success()(internal_auth.c 第 56 行)则读取 ( w(?c) ),把 failure 带的消息变成 SVN_ERR_RA_NOT_AUTHORIZED:

  if (strcmp(status, "failure") == 0 && arg)
    return svn_error_createf(SVN_ERR_RA_NOT_AUTHORIZED, NULL,
                             _("Authentication error from server: %s"), arg);
2.5.3 认证后:repos-info

认证成功后,服务端发:

  repos-info: ( uuid:string repos-url:string ( cap:word ... ) )
  • uuid:仓库的通用唯一标识;
  • repos-url:仓库根目录的 URL;
  • cap:仓库能力——规范强调,这是"需要服务端与仓库都支持"才能宣称的能力(例如 SVN_RA_SVN_CAP_MERGEINFO)。

服务端的实现里有一段注释和一个技巧(serve.c 第 4375–4396 行):

  /* We can't claim mergeinfo capability until we know whether the
     repository supports mergeinfo (i.e., is not a 1.4 repository),
     but we don't get the repository url from the client until after
     we've already sent the initial list of server capabilities.  So
     we list repository capabilities here, in our first response after
     the client has sent the url. */
  {
    svn_boolean_t supports_mergeinfo;
    SVN_ERR(svn_repos_has_capability(b->repository->repos,
                                     &supports_mergeinfo,
                                     SVN_REPOS_CAPABILITY_MERGEINFO,
                                     scratch_pool));

    SVN_ERR(svn_ra_svn__write_tuple(conn, scratch_pool, "w(cc(!",
                                    "success", b->repository->uuid,
                                    b->repository->repos_url));
    if (supports_mergeinfo)
      SVN_ERR(svn_ra_svn__write_word(conn, scratch_pool,
                                     SVN_RA_SVN_CAP_MERGEINFO));
    SVN_ERR(svn_ra_svn__write_tuple(conn, scratch_pool, "!))"));
    SVN_ERR(svn_ra_svn__flush(conn, scratch_pool));
  }

注意这里的格式串写作技巧:"w(cc(!" 用 ! 表示"不写这个子元组的右括号",接着根据仓库是否支持 mergeinfo 选择性写一个 word,最后用 "!))" 补上两个右括号。这利用了 1.9 节讲的 ! 语义——把一次性的、可选的能力词拼进同一条 repos-info 里。

客户端读 repos-info(client.c 第 770 行):

  SVN_ERR(svn_ra_svn__read_cmd_response(conn, pool, "c?c?l", &conn->uuid,
                                        &conn->repos_root, &repos_caplist));
  if (repos_caplist)
    SVN_ERR(svn_ra_svn__set_capabilities(conn, repos_caplist));

格式串 "c?c?l" 表示:第一个 string(uuid)、可选的 string(repos-root)、可选的 list(仓库能力)。第二个 ? 之后的 l 也是可选的,因为 repos-caplist 可能为空。

2.6 一次完整的手写 svn:// 会话(硬示例)

假设:服务端 svnserve -d -r /srv/svn 服务仓库 /srv/svn/repo,svnserve.conf 里 anon-access = read、realm = example-realm;客户端 URL svn://localhost/repo、实现标识 SVN/1.15.0;仓库 uuid 记作 <uuid>(36 字节)。下面给出线上字节流:每条消息后空一行表示 item 终止空白(真实实现里可能只是一个空格或换行),# 后为该消息的字段标注。

# ① 服务端 → greeting:minver=2 maxver=2(v1 已废弃),空 mechs,13 个服务端能力
( success ( 2 2 ( ) ( edit-pipeline svndiff1 accepts-svndiff2 absent-entries
  commit-revprops depth log-revprops atomic-revprops partial-replay
  inherited-props ephemeral-txnprops file-revs-reverse list ) ) )

# ② 客户端 → response:选定版本=2,7 个客户端能力,url=20 字节,ra-client=10 字节(可选 client 省略)
( 2 ( edit-pipeline svndiff1 accepts-svndiff2 absent-entries depth mergeinfo
  log-revprops ) 20:svn://localhost/repo 10:SVN/1.15.0 )

# ③ 服务端 → auth-request:机制数组只有 ANONYMOUS,realm 13 字节
( success ( ( ANONYMOUS ) 13:example-realm ) )

# ④ 客户端 → auth-response:选 ANONYMOUS,无 token
( ANONYMOUS )

# ⑤ 服务端 → challenge:success(用 "w()" 写出)
( success )

# ⑥ 服务端 → repos-info:uuid 36 字节,仓库根 URL,仓库能力 mergeinfo
( success ( 36:<uuid> 20:svn://localhost/repo ( mergeinfo ) ) )

# ⑦ 客户端 → reparent(原型:params ( url:string );响应为空元组)
( reparent ( 20:svn://localhost/repo ) )

# ⑧ 服务端 → 发命令响应前先发一个 auth-request(机制列表为空=无需新认证),随后是 reparent 响应
( success ( ) )

# ⑨ 客户端 → get-latest-rev(params 空元组;响应 ( rev:number ))
( get-latest-rev ( ) )

# ⑩ 服务端 → get-latest-rev 响应:最新版本号 42
( success ( 42 ) )

注意 ⑧ 处体现的规范约束(3.1.1 节):"After each main command is issued by the client, the server sends an auth-request"——每条主命令后服务端都先发 auth-request;无需新认证时它带空机制列表,服务端随即发命令响应。

这 10 步是一次最简 svn:// 会话的骨架。真实的 svn checkout/update 会在 ⑩ 之后进入 report 命令集(发 update、切集、发 set-path/finish-report),服务端再切到 editor 命令集反向驱动客户端(target-rev/open-root/add-file/apply-textdelta/textdelta-chunk/close-file/close-dir/close-edit),最后发一条命令响应。这些命令的细节见下一节。


3. 命令集:主命令集、编辑器命令集、报告命令集

规范 3.1 节定义了三个命令集(command sets):

  • 主命令集(main command set):对应 svn_ra 接口层。初始状态下,协议处于主命令集,由客户端发起命令。
  • 编辑器命令集(editor command set):由"驱动方"(可能是服务端)发起,用来描述一棵目录树的变更。
  • 报告命令集(report command set):由客户端发起,用来告诉服务端"我本地有什么、我想更新到哪里"。

三条集之间存在流向切换:某些主命令可以让双方切换命令集,甚至反转控制方向。规范 3.1 节原文:"the protocol begins in the main command set with the client sending commands; some commands can change the command set and possibly the direction of control."

3.1 主命令集

规范 3.1.1 节开门见山:"The main command set corresponds to the svn_ra interfaces. After each main command is issued by the client, the server sends an auth-request as described in section 2.(If no new authentication is required, the auth-request contains an empty mechanism list, and the server proceeds immediately to sending the command response.)"——每条主命令发起后,服务端都会先发一个 auth-request;如果不需要新认证,它就带一个空机制列表,服务端随即发命令响应。这也解释了 2.6 节第 ⑧ 步为什么要先看到 auth-request。

1.15 服务端注册的主命令表就在 svnserve/serve.c 第 3784 行:

static const svn_ra_svn__cmd_entry_t main_commands[] = {
  { "reparent",        reparent },
  { "get-latest-rev",  get_latest_rev },
  { "get-dated-rev",   get_dated_rev },
  { "change-rev-prop", change_rev_prop },
  { "change-rev-prop2",change_rev_prop2 },
  { "rev-proplist",    rev_proplist },
  { "rev-prop",        rev_prop },
  { "commit",          commit },
  { "get-file",        get_file },
  { "get-dir",         get_dir },
  { "update",          update },
  { "switch",          switch_cmd },
  { "status",          status },
  { "diff",            diff },
  { "get-mergeinfo",   get_mergeinfo },
  { "log",             log_cmd },
  { "check-path",      check_path },
  { "stat",            stat_cmd },
  { "get-locations",   get_locations },
  { "get-location-segments",   get_location_segments },
  { "get-file-revs",   get_file_revs },
  { "lock",            lock },
  { "lock-many",       lock_many },
  { "unlock",          unlock },
  { "unlock-many",     unlock_many },
  { "get-lock",        get_lock },
  { "get-locks",       get_locks },
  { "replay",          replay },
  { "replay-range",    replay_range },
  { "get-deleted-rev", get_deleted_rev },
  { "get-iprops",      get_inherited_props },
  { "list",            list },
  { NULL }
};

下面按规范(原型)与服务端实现(能力门控)逐条说明。为节省篇幅,把这一批命令归成几组。

3.1.1 会话与版本号类

reparent —— 把会话重新挂到另一个 URL:

  reparent
    params:   ( url:string )
    response: ( )

用途:当客户端要访问同一仓库里的另一个路径,但又不希望重新握手/重连时,就发 reparent 让服务端把"当前会话的根"换掉。

get-latest-rev —— 取仓库最新版本号:

  get-latest-rev
    params:   ( )
    response: ( rev:number )

get-dated-rev —— 取"某个时间点对应的版本号":

  get-dated-rev
    params:   ( date:string )
    response: ( rev:number )

date 是一个 ISO 8601 风格的时间字符串(由 svn_time_to_cstring() 生成)。服务端对应实现里正是这样编码的(marshal.c 第 2359 行 svn_ra_svn__write_cmd_get_dated_rev):

  SVN_ERR(svn_ra_svn__write_cmd_get_dated_rev(...));
  /* 内部: */
  SVN_ERR(writebuf_write_literal(conn, pool, "( get-dated-rev ( "));
  SVN_ERR(write_tuple_cstring(conn, pool, svn_time_to_cstring(tm, pool)));
  SVN_ERR(writebuf_write_literal(conn, pool, ") ) "));
3.1.2 版本属性(rev-prop)类

change-rev-prop(旧式):

  change-rev-prop
    params:   ( rev:number name:string ? value:string )
    response: ( )

规范特意注了一段说明:"If value is not specified, the rev-prop is removed.(Originally the value was required; for minimum impact, it was changed to be optional without creating an optional tuple for that one parameter as we normally do.)"——value 不写就表示删除该版本属性。这是"让单个参数变可选"的一个历史特例:它没有用常规的 [ ... ] 包裹,而是直接把 ? 加在这个参数前。

change-rev-prop2(原子式):

  change-rev-prop2
    params:   ( rev:number name:string [ value:string ]
                ( dont-care:bool ? previous-value:string ) )
    response: ( )

语义(规范原文):value 缺省表示删除;若 dont-care 为 false,则"仅当当前值等于 previous-value 时才修改"(若 dont-care 为 false 而 previous-value 未指定,则要求该 revprop 之前未被设置);若 dont-care 为 true,则 previous-value 必须不出现。

能力门控:规范 2.1 节说 atomic-revprops 能力对应 change-rev-prop2;服务端只有在仓库/服务端支持该能力时才应宣称它。

rev-proplist —— 列出某版本的全部属性:

  rev-proplist
    params:   ( rev:number )
    response: ( props:proplist )

rev-prop —— 读某版本的单个属性:

  rev-prop
    params:   ( rev:number name:string )
    response: ( [ value:string ] )

proplist 的原型(规范 3 节)是:

  proplist:  ( ( name:string value:string ) ... )

即"一个数组,每个元素是一个 (名字 值) 元组"。marshal.c 的 svn_ra_svn__write_proplist()(第 798 行)就是按这个结构编码的。

3.1.3 提交类

commit:

  commit
    params:   ( logmsg:string ? ( ( lock-path:string lock-token:string ) ... )
                keep-locks:bool ? rev-props:proplist )
    response: ( )

规范对流程有详细说明:"Upon receiving response, client switches to editor command set.(收到响应后,客户端切到编辑器命令集。)Upon successful completion of edit, server sends auth-request.(编辑成功完成后,服务端发 auth-request。)After auth exchange completes, server sends commit-info.(认证交换完成后,服务端发 commit-info。)"

还特别注明:"If rev-props is present, logmsg is ignored. Only the svn:log entry in rev-props (if any) will be used."——若带了 rev-props,logmsg 被忽略,只用 rev-props 里的 svn:log。

commit-info 原型:

    commit-info: ( new-rev:number date:string author:string
                   ? ( post-commit-err:string ) )

即提交成功后服务端返回"新版本号 + 日期 + 作者",以及可选的 post-commit 钩子错误——后者是"提交其实成功了,但提交后钩子报了错"的独特信号。

服务端编码 commit 请求的实现在 client.c 第 1332 行附近:

  SVN_ERR(svn_ra_svn__write_tuple(conn, pool, "w(c(!", "commit",
                                  log_msg->data));
  /* 若有锁定令牌,逐个写入 ( lock-path lock-token ) */
  for (...)
      SVN_ERR(svn_ra_svn__write_tuple(conn, iterpool, "cc", path, token));
  SVN_ERR(svn_ra_svn__write_tuple(conn, pool, "!)b(!", keep_locks));
  /* 若有 revprops,继续写 proplist */
  SVN_ERR(svn_ra_svn__write_tuple(conn, pool, "!))"));
3.1.4 取内容类:get-file 与 get-dir

get-file:

  get-file
    params:   ( path:string [ rev:number ] want-props:bool want-contents:bool
                ? want-iprops:bool )
    response: ( [ checksum:string ] rev:number props:proplist
                [ inherited-props:iproplist ] )

规范注意点:

  • "If want-contents is specified, then after sending response, server sends file contents as a series of strings, terminated by the empty string, followed by a second empty command response to indicate whether an error occurred during the sending of the file."——若想要内容,响应发完之后,服务端把文件内容作为一串字符串送出,以空字符串结束,然后再发第二个空命令响应以表示发送期间是否出错;
  • 关于 want-iprops:"the standard client doesn't send want-iprops as true, it uses get-iprops, but does send want-iprops as false to workaround a server bug in 1.8.0-1.8.8."——这正是 1.10 节末尾那个 workaround 的规范级出处。

get-dir:

  get-dir
    params:   ( path:string [ rev:number ] want-props:bool want-contents:bool
                ? ( field:dirent-field ... ) ? want-iprops:bool )
    response: ( rev:number props:proplist ( entry:dirent ... )
                [ inherited-props:iproplist ] )
    dirent:   ( name:string kind:node-kind size:number has-props:bool
                created-rev:number [ created-date:string ]
                [ last-author:string ] )
    dirent-field: kind | size | has-props | created-rev | time | last-author
                  | word

dirent-field 是一个枚举 word 数组,限定"只取哪些字段"(kind/size/has-props/created-rev/time/last-author),末尾的 | word 表示"也允许未来扩展的字段名"(老实现忽略未知字段)。

3.1.5 编辑驱动类:update / switch / status / diff

这四条命令都是"客户端发起、但流程中会切换命令集并反转控制"的:

  update
    params:   ( [ rev:number ] target:string recurse:bool
                ? depth:word send_copyfrom_args:bool ? ignore_ancestry:bool )
    response: ( )

  switch
    params:   ( [ rev:number ] target:string recurse:bool url:string
                ? depth:word ? send_copyfrom_args:bool ignore_ancestry:bool )
    response: ( )

  status
    params:   ( target:string recurse:bool ? [ rev:number ] ? depth:word )
    response: ( )

  diff
    params:   ( [ rev:number ] target:string recurse:bool ignore-ancestry:bool
                url:string ? text-deltas:bool ? depth:word )
    response: ( )

四条命令共享同一段流程注释(规范 3.1.1 节):

Client switches to report command set.(客户端切到报告命令集。) Upon finish-report, server sends auth-request.(finish-report 之后,服务端发 auth-request。) After auth exchange completes, server switches to editor command set.(认证交换完成后,服务端切到编辑器命令集。) After edit completes, server sends response.(编辑完成后,服务端发响应。)

值得注意的是 diff ——1.10 节已经讲过它是"扩展既有命令"的范本;此外 diff 的 url 参数在 status/update 里没有,因为 diff 需要"与哪个 URL 比"。text-deltas 参数控制"要不要真的发文本增量,还是只发'这个文件变了'的空窗口"。

3.1.6 日志与历史类

log:

  log
    params:   ( ( target-path:string ... ) [ start-rev:number ]
                [ end-rev:number ] changed-paths:bool strict-node:bool
                ? limit:number
                ? include-merged-revisions:bool
                all-revprops | revprops ( revprop:string ... ) )
    ...
    log-entry: ( ( change:changed-path-entry ... ) rev:number
                 [ author:string ] [ date:string ] [ message:string ]
                 ? has-children:bool invalid-revnum:bool
                 revprop-count:number rev-props:proplist
                 ? subtractive-merge:bool )
             | done
    changed-path-entry: ( path:string A|D|R|M
                          ? ( ? copy-path:string copy-rev:number )
                          ? ( ? node-kind:string ? text-mods:bool prop-mods:bool ) )
    response: ( )

要点:

  • 服务端在发响应之前先发一批 log 条目,以字面量 done 结束;
  • "If a client does not want to specify a limit, it should send 0 as the limit parameter."——不想限流就发 0;
  • "rev-props excludes author, date, and log; they are sent separately for backwards-compatibility."——rev-props 里不含 author/date/log,这三个为了向后兼容而单独发(log-entry 里的 [ author ]/[ date ]/[ message ]);
  • 最后一段是"属性选择":要么字面量 all-revprops,要么字面量 revprops 紧跟一个 revprop 名数组。

客户端发 log 请求的实现在 client.c 第 1873 行附近:先 svn_ra_svn__write_tuple(conn, pool, "w((!", "log") 并逐个写 target-path,再用 "!)(?r)(?r)bbnb!" 写 start/end 版本等,最后若带 revprop 名则用 "!w(!", "revprops"、否则用 "!w())", "all-revprops" 收尾。

check-path —— 判断某路径在某版本里的节点类型:

  check-path
    params:   ( path:string [ rev:number ] )
    response: ( kind:node-kind )
    If path is non-existent, 'svn_node_none' kind is returned.

stat —— 取 dirent(比 check-path 信息更多):

  stat
    params:   ( path:string [ rev:number ] )
    response: ( ? entry:dirent )
    dirent:   ( kind:node-kind size:number has-props:bool
                created-rev:number [ created-date:string ]
                [ last-author:string ] )
    New in svn 1.2.  If path is non-existent, an empty response is returned.

get-mergeinfo:

  get-mergeinfo
    params:   ( ( path:string ... ) [ rev:number ] inherit:word
                descendants:bool)
    response: ( ( ( path:string merge-info:string ) ... ) )
    New in svn 1.5.  If no paths are specified, an empty response is
    returned.  If rev is not specified, the youngest revision is used.

get-locations / get-location-segments:

  get-locations
    params:   ( path:string peg-rev:number ( rev:number ... ) )
    响应前服务端先发 location-entry,以 "done" 结束:
      location-entry: ( rev:number abs-path:number ) | done
    response: ( )

  get-location-segments
    params:   ( path:string [ start-rev:number ] [ end-rev:number ] )
    响应前服务端先发 location-entry,以 "done" 结束:
      location-entry: ( range-start:number range-end:number [ abs-path:string ] ) | done
    response: ( )

get-file-revs:

  get-file-revs
    params:   ( path:string [ start-rev:number ] [ end-rev:number ]
                ? include-merged-revisions:bool )
    响应前服务端先发 file-rev,以 "done" 结束:
      file-rev: ( path:string rev:number rev-props:proplist
                  file-props:propdelta ? merged-revision:bool ) | done
    每条 file-rev 之后,文件增量以若干 string 发送、以空串结束(无增量则只发结束符)。
    response: ( )

get-deleted-rev:

  get-deleted-rev
    params:   ( path:string peg-rev:number end-rev:number )
    response: ( deleted-rev:number )

get-iprops:

  get-iprops
    params:   ( path:string [ rev:number ] )
    response: ( inherited-props:iproplist )
    New in svn 1.8.  If rev is not specified, the youngest revision is used.

iproplist 原型(规范 3 节):

  iproplist: ( ( name:string proplist ) ... )

即"数组,每个元素是 (属性名 该属性是个 proplist)"——继承属性每一层是一个 proplist。

3.1.7 锁定类
  lock         params: ( path:string [ comment:string ] steal-lock:bool
                         [ current-rev:number ] )   response: ( lock:lockdesc )

  lock-many    params: ( [ comment:string ] steal-lock:bool
                         ( ( path:string [ current-rev:number ] ) ... ) )
               响应前服务端逐条发 lock-info:
                 ( success ( lock:lockdesc ) ) | ( failure ( err:error ) ) | done
               response: ( )

  unlock       params: ( path:string [ token:string ] break-lock:bool )   response: ( )

  unlock-many  params: ( break-lock:bool ( ( path:string [ token:string ] ) ... ) )
               响应前服务端逐个发:
                 ( success ( path:string ) ) | ( failure ( err:error ) ) | done
               response: ( )

  get-lock     params: ( path:string )                    response: ( [ lock:lockdesc ] )
  get-locks    params: ( path:string ? [ depth:word ] )   response: ( ( lock:lockdesc ... ) )

  lockdesc: ( path:string token:string owner:string [ comment:string ]
              created:string [ expires:string ] )

客户端解析 lockdesc 在 client.c 第 218 行 parse_lock(),格式串为 "ccc(?c)c(?c)"(path/token/owner、可选 comment、created、可选 expires)。

lock-many/unlock-many 的"批量"设计非常关键:单条 lock 一次只能锁一个路径(N 次锁 = N 次往返),而 lock-many 把所有路径塞进一个请求,服务端再逐条流式回 lock-info(成功/失败/done)。1.15 客户端用这个来加速大批量加锁。

3.1.8 重放类:replay 与 replay-range
  replay
    params:    ( revision:number low-water-mark:number send-deltas:bool )
    After auth exchange completes, server switches to editor command set.
    After edit completes, server sends response.
    response   ( )

  replay-range
    params:    ( start-rev:number end-rev:number low-water-mark:number
                 send-deltas:bool )
    After auth exchange completes, server sends each revision
    from start-rev to end-rev, alternating between sending 'revprops'
    entries and sending the revision in the editor command set.
    After all revisions are complete, server sends response.
    revprops:  ( revprops:word props:proplist )
      (revprops here is the literal word "revprops".)
    response   ( )

replay-range 是 svnsync 之类工具的核心:服务端把一串版本的编辑操作连续重放给客户端。注意每个版本前先发一个 ( revprops <proplist> ) 条目(revprops 是字面量 word)。

3.1.9 list
  list
    params:   ( path:string [ rev:number ] depth:word
                ( field:dirent-field ... ) ? ( pattern:string ... ) )
    Before sending response, server sends dirents, ending with "done".
    dirent:   ( rel-path:string kind:node-kind
                ? [ size:number ] [ has-props:bool ] [ created-rev:number ]
                  [ created-date:string ] [ last-author:string ] )
              | done
    dirent-field: kind | size | has-props | created-rev | time | last-author
                  | word
    response: ( )
    New in svn 1.10.  If rev is not specified, the youngest revision is used.
    If the dirent-fields don't contain "kind", "unknown" will be returned
    in the kind field.

list 相对 get-dir 的好处是"相对路径(rel-path)+ 深度 + 字段选择"的组合,适合"列出一棵树的扁平清单"。

3.2 编辑器命令集

规范 3.1.2 节开篇讲了一个极为重要的错误处理约定:

An edit operation produces only one response, at close-edit or abort-edit time. However, the consumer may write an error response at any time during the edit in order to terminate the edit operation early; the driver must notice that input is waiting on the connection, read the error, and send an abort-edit operation. After an error is returned, the consumer must read and discard editing operations until the abort-edit. In order to prevent TCP deadlock, the consumer must use non-blocking I/O to send an early error response; if writing blocks, the consumer must read and discard edit operations until writing unblocks or it reads an abort-edit.

这段话解释了为什么 ra_svn 的网络层里有"块处理器(block handler)"和非阻塞写机制——它们是用来避免双方同时写导致 TCP 死锁的。对应到实现,就是 libsvn_ra_svn/editorp.c 驱动的编辑器,以及 marshal.c 里的 conn->block_handler 与 svn_ra_svn__set_block_handler()。

编辑器命令集的全部命令(规范 3.1.2):

  target-rev       params: ( rev:number )
  open-root        params: ( [ rev:number ] root-token:string )
  delete-entry     params: ( path:string rev:number dir-token:string )
  add-dir          params: ( path:string parent-token:string child-token:string
                             [ copy-path:string copy-rev:number ] )
  open-dir         params: ( path:string parent-token:string child-token:string rev:number )
  change-dir-prop  params: ( dir-token:string name:string [ value:string ] )
  close-dir        params: ( dir-token:string )
  absent-dir       params: ( path:string parent-token:string )
  add-file         params: ( path:string dir-token:string file-token:string
                             [ copy-path:string copy-rev:number ] )
  open-file        params: ( path:string dir-token:string file-token:string rev:number )
  apply-textdelta  params: ( file-token:string [ base-checksum:string ] )
  textdelta-chunk  params: ( file-token:string chunk:string )
  textdelta-end    params: ( file-token:string )
  change-file-prop params: ( file-token:string name:string [ value:string ] )
  close-file       params: ( file-token:string [ text-checksum:string ] )
  absent-file      params: ( path:string parent-token:string )
  close-edit       params: ( )   response: ( )
  abort-edit       params: ( )   response: ( )
  finish-replay    params: ( )   (仅服务端→客户端,标志 replay 结束)

几个关键约定:

  • token(令牌):每个被打开的目录/文件都有一个"token"字符串,用来在后续命令里指代它。这避免了每次都要重复完整路径。open-root 给出 root-token,add-dir/open-dir 给出 child-token,等等。
  • copy-path+copy-rev:在 [ ... ] 里,表示"这个新增节点其实是别处某个路径某版本的拷贝(copyfrom)"。
  • absent-dir/absent-file:只在对方宣称 absent-entries 能力时才会被发/收(见 2.4 表)。
  • 文本增量用三个命令:apply-textdelta(声明要开始改这个文件的正文,可带 base-checksum)、若干 textdelta-chunk(携带 svndiff 分块)、textdelta-end(结束)。
  • close-file 可带 text-checksum:用于校验结果正文。
  • finish-replay:只从服务端发给客户端,标志一次 replay 结束。

3.3 报告命令集

规范 3.1.3 节先说明其"无响应"特性:"To reduce round-trip delays, report commands do not return responses. Any errors resulting from a report call will be returned to the client by the command which invoked the report (following an abort-edit call). Errors resulting from an abort-report call are ignored."——报告命令不回响应(为了减少往返延迟);报告里出的错,会由"发起该报告的那条主命令"(在 abort-edit 之后)返回给客户端;而 abort-report 自身的错误被忽略。

报告命令集全部命令:

  set-path      params: ( path:string rev:number start-empty:bool
                          ? [ lock-token:string ] ? depth:word )
  delete-path   params: ( path:string )
  link-path     params: ( path:string url:string rev:number start-empty:bool
                          ? [ lock-token:string ] ? depth:word )
  finish-report params: ( )
  abort-report  params: ( )
  • set-path:告诉服务端"我本地这个路径的已知版本是 rev、是否为空(start-empty)、有没有锁令牌、深度如何"。服务端据此判断要发哪些编辑操作。
  • delete-path:告诉服务端"我本地这个路径已经不存在了"。
  • link-path:switch 用得上——本地路径 path 其实指向服务端的另一个 url(切换目标),版本 rev、start-empty、锁令牌、深度。
  • 规范还提出 1.10 节末尾讲过的"向后兼容纪律":set-path 的 lock-token 必须放在 [ ... ] 里,且即使没有锁令牌也要发一个空元组占位,好让未来扩展。
  • finish-report:报告结束;随后服务端发 auth-request,切到编辑器命令集。
  • abort-report:报告中止。

服务端注册的报告命令表(serve.c 第 966 行)与之精确对应:

static const svn_ra_svn__cmd_entry_t report_commands[] = {
  { "set-path",      set_path },
  { "delete-path",   delete_path },
  { "link-path",     link_path },
  { "finish-report", finish_report, NULL, TRUE },
  { "abort-report",  abort_report,  NULL, TRUE },
  { NULL }
};

注意 finish-report/abort-report 的第四个字段是 TRUE(即 cmd_entry_t 的 terminate 标志):处理完这条命令后,报告命令集的循环就结束。这与 marshal.c 里 svn_ra_svn__handle_commands2() 的 if (terminate) break; 呼应。

3.4 报文示例:一次 update 的报告与编辑交错

下面把"客户端报告"与"服务端编辑驱动"交错起来,给出一次 svn update(目标版本 42、新增 trunk/README)的骨架报文。注意报告命令不回响应,客户端连发;服务端在 finish-report 之后才发 auth-request 并开始反向驱动。# 后为字段标注。

# 客户端 → update 主命令:rev=42, target=""(空串写成 0:), recurse=true,
#                         depth=infinity, send_copyfrom=false, ignore_ancestry=true
( update ( 42 ) 0: 1:true 8:infinity 1:false 1:true )

# 客户端 → report 命令集 set-path:空路径, rev=42, start-empty=false,
#                 锁令牌用空元组 ( ) 占位(协议要求即使没有也要发), depth=infinity
( set-path ( 0: 42 1:false ( ) 8:infinity ) )

# 客户端 → finish-report(报告结束)
( finish-report ( ) )

# 服务端 → auth-request:空机制列表 = 不需要新认证
( success ( ( ) 0: ) )

# 服务端 → 编辑器命令集:目标版本
( target-rev ( 42 ) )

# 服务端 → open-root:根令牌 root-1
( open-root ( ( 42 ) 6:root-1 ) )

# 服务端 → add-dir:新增 trunk,父令牌 root-1,子令牌 dir-2,非拷贝(空元组)
( add-dir ( 5:trunk 6:root-1 5:dir-2 ( ) ) )

# 服务端 → add-file:在 trunk 下新增 README,目录令牌 dir-2,文件令牌 file-3
( add-file ( 11:trunk/README 5:dir-2 6:file-3 ( ) ) )

# 服务端 → apply-textdelta:对 file-3 应用文本增量,带 base-checksum(32 字节 MD5)
( apply-textdelta ( 6:file-3 ( 32:a1b2c3d4e5f60718293a4b5c6d7e8f90 ) ) )

# 服务端 → textdelta-chunk:一个 svndiff 分块(内容用占位表示)
( textdelta-chunk ( 6:file-3 1234:<svndiff 窗口字节> ) )

# 服务端 → textdelta-end
( textdelta-end ( 6:file-3 ) )

# 服务端 → close-file:关闭文件,附结果校验和
( close-file ( 6:file-3 ( 32:<result-md5-hex> ) ) )

# 服务端 → close-dir / close-edit
( close-dir ( 5:dir-2 ) )
( close-edit ( ) )

# 服务端 → 最终命令响应(update 的 response 原型是空元组)
( success ( ) )

真实的更新会更长(每个被改动节点对应一组命令),但结构完全一致:target-rev → open-root → 逐节点 open-dir/add-dir/open-file/add-file/change-*-prop/apply-textdelta… → close-dir → close-edit → 命令响应。


4. svnserve:服务端实现

4.1 文件构成(Glob 实证)

subversion/svnserve/ 目录下的文件(用文件搜索实证)是:

svnserve/svnserve.c         主控:参数解析、监听、accept 循环、信号
svnserve/serve.c            协议循环:greeting、认证、命令分发、报告驱动
svnserve/logger.c / .h      运营日志(log-file)
svnserve/server.h           服务端数据结构(repository_t/server_baton_t/serve_params_t/connection_t)
svnserve/cyrus_auth.c       Cyrus SASL 认证
svnserve/winservice.c/.h    Windows 服务(Service Control Manager)支持
svnserve/log-escape.c       日志转义
svnserve/svnserve.conf.5    配置手页
svnserve/svnserve.8         命令手页

注意:任务描述里提到的 main.c 在这个版本里并不存在——主控逻辑在 svnserve.c 里(其入口是 SVN_CMDLINE__MAIN 宏展开的 main)。这一点必须按实证来写,不能想当然。

4.2 五种运行模式

svnserve.c 用两个枚举描述运行方式(第 78、85 行):

/* The strategy for handling incoming connections. */
enum connection_handling_mode {
  connection_mode_fork,   /* 每个连接 fork 一个进程 */
  connection_mode_thread, /* 每个连接创建一个线程 */
  connection_mode_single  /* 本进程一次只处理一个连接 */
};

/* The mode in which to run svnserve */
enum run_mode {
  run_mode_unspecified,
  run_mode_inetd,
  run_mode_daemon,
  run_mode_tunnel,
  run_mode_listen_once,
  run_mode_service
};

命令行开关与之对应(svnserve__options[],第 239 行起):-d/--daemon、-i/--inetd、-t/--tunnel(由 svn+ssh:// 触发)、-X/--listen-once(调试)、--service(Windows 服务);-r/--root(服务根)、-R/--read-only;--tunnel-user、-c/--compression(0=不压缩,5=默认,9=最大)、-M/--memory-cache-size(MB,默认 16);--cache-txdeltas/--cache-fulltexts/--cache-revprops/--cache-nodeprops、--client-speed、--block-read;-T/--threads、--min-threads/--max-threads、--max-request-size/--max-response-size、--virtual-host、--log-file/--pid-file、-q/--quiet。

模式选择有严格校验(第 1041 行):if (mode_opt_count != 1) { ... "You must specify exactly one of -d, -i, -t or -X.\n" ... }——必须且只能指定一个运行模式;连接处理模式也有限制(第 1056 行):-T 与 --single-thread 不能同时用。

4.3 连接处理策略与默认值

连接处理模式的默认值取决于平台能力(第 94–113 行):有 fork 就默认 fork(Unix 常见情形);只有线程则默认 thread;两者都没有才退化为 single(宏 CONNECTION_DEFAULT)。

线程模式用 APR 线程池,参数在第 115–148 行:THREADPOOL_MIN_SIZE=1;THREADPOOL_MAX_SIZE 在 32 位平台为 64、64 位平台为 256(注释解释:"每个线程会在 APR 分配器的根池里分配最多 4MB 未用 RAM,32 位服务器因此必须少用线程");THREADPOOL_THREAD_IDLE_LIMIT=1000000、ACCEPT_BACKLOG=128、MAX_REQUEST_SIZE=16(MB)。

线程模式还有一个自适应负载判断(is_busy(),第 640 行):

  return apr_thread_pool_threads_count(threads) * 2
       > apr_thread_pool_thread_max_get(threads);

"活跃线程数超过池上限的一半就认为繁忙"——此时连接不再阻塞在 socket read 上而是轮流 poll,避免慢连接长期霸占线程(这正是 serve_interruptable() 的用途)。

4.4 SIGTERM / SIGINT 处理(1.15 新增)

CHANGES 在 1.15.0 的服务端改进里写着:

    * svnserve: Add SIGTERM/SIGINT handling (r1908547, r1934784)

代码实证在 svnserve/svnserve.c 第 497 行:

#ifdef APR_HAVE_SIGACTION
static volatile svn_atomic_t sigtermint_seen = 0;
static void sigtermint_handler(int signo) { svn_atomic_set(&sigtermint_seen, 1); }
#endif

处理器只做一件信号处理器里唯一安全的事——原子置位一个标志。退出逻辑在 accept_connection()(第 533 行)与主循环(第 1360–1373 行)里各查一次:先 apr_signal(SIGTERM, sigtermint_handler); apr_signal(SIGINT, sigtermint_handler);,然后每次 accept_connection() 返回都 svn_atomic_read(&sigtermint_seen),为真就 break。

设计要点:用"打断 accept()"实现优雅停机——信号把标志置 1,accept() 被打断返回 EINTR;即使 accept 成功返回,随后的原子读也会 break,于是主函数走到收尾(销毁线程池/根池、关闭 socket)。这与 Windows 服务模式的 winservice_notify_stop()(第 191 行,直接 closesocket 打断 accept)思路一致,只是跨平台。

对比:svnserve.c 另有一个 sigchld_handler()(第 491 行,仅 APR_HAS_FORK 下编译),注释是 "Nothing to do; we just need to interrupt the accept().",仅用于打断 accept 以回收僵尸子进程(随后 accept_connection 用 apr_proc_wait_all_procs(..., APR_NOWAIT, ...) 循环收割)。两者同属"用信号打断阻塞系统调用",但 r1908547/r1934784 补的是外部可控的停机路径,不是回收子进程。

4.5 svnserve.conf 配置项

svnserve/svnserve.conf.5 是这份配置的手页。文件位于仓库的 conf/svnserve.conf,结构是"节 + 变量"。它只用一个节 [general],支持以下变量:

  • anon-access = none|read|write:未认证用户的访问级别。write 允许所有操作;read 允许除提交与改版本属性外的所有操作;none 不允许任何访问。默认 read。
  • auth-access = none|read|write:已认证用户的访问级别,取值同上。默认 write。
  • password-db = filename:密码数据库位置,可相对仓库 conf 目录。无默认值。密码库格式同本文件,只用 [users] 节,每个变量是"用户名 = 密码"。
  • authz-db = path:路径级访问控制规则的位置。path 可相对仓库 conf 目录,也可以是"仓库相对 URL(^/)或一个指向 Subversion 仓库内文本的绝对 file:// URL"。无默认值;不指定则不做路径级访问控制。
  • realm = realm-name:认证 realm。手页强调"若两个仓库共用同一密码库,它们应当共用同一 realm,反之亦然;这一关联让客户端可以对多个仓库共用一个缓存密码"。默认值是仓库的 uuid。

手页给的示例:

 [general]
 anon-access = none
 auth-access = read
 password-db = passwd
 realm = My First Repository

配套的 passwd:

 [users]
 joeuser = joepassword
 jayrandom = randomjay

代码侧,这些变量名映射到 svn_config 的宏:set_access()(serve.c 第 559 行)调用 get_access(cfg, SVN_CONFIG_OPTION_AUTH_ACCESS, "write", read_only) 与 get_access(cfg, SVN_CONFIG_OPTION_ANON_ACCESS, "read", read_only);而 get_access()(第 540 行)把字符串映射成枚举——"write" → WRITE_ACCESS、"read" → READ_ACCESS、其它 → NO_ACCESS,并且最后一行是只读降级:return result == WRITE_ACCESS && read_only ? READ_ACCESS : result;,即命令行 -R 会把 write 强制降成 read。

访问级别枚举定义在 svnserve/server.h 第 47 行:enum access_type { NO_ACCESS, READ_ACCESS, WRITE_ACCESS };。它是有序的(NO_ACCESS < READ_ACCESS < WRITE_ACCESS),因此 serve.c 里可以写 b->repository->anon_access >= required 这种比较——"匿名级别至少达到所需级别"。

4.6 server.h 的数据结构

serve_params_t(server.h 第 96 行)是进程级(整台服务器共享)参数,涵盖了 4.2 节里那些命令行选项解析后要放的位置:root、tunnel、tunnel_user、read_only、base、cfg、logger、config_pool、fs_config、username_case、memory_cache_size、compression_level、zero_copy_limit、error_check_interval、max_request_size、max_response_size、vhost。

repository_t(server.h 第 49 行)是单仓库信息,包括打开好的 svn_repos_t *repos、svn_fs_t *fs、解析好的密码库 svn_config_t *pwdb、authz 规则 svn_authz_t *authzdb、realm、url、uuid、客户端能力数组 capabilities、以及最终的 auth_access/anon_access。

client_info_t(server.h 第 78 行)是单客户端信息:user(认证后的用户名)、remote_host、authz_user(user 经大小写归一后的形式)、tunnel、tunnel_user。

connection_t(server.h 第 167 行)把"连接"独立出来:socket、参数指针、baton、ra_svn 连接对象、连接生命期的内存池,以及用于线程安全的 svn_atomic_t ref_count。attach_connection()/close_connection()(svnserve.c 第 592、601 行)围绕这个引用计数——所有引用都释放后才销毁连接池,这是线程模式下避免"主线程已清理、工作线程还在用"的关键。

4.7 serve.c:认证与鉴权链路

serve() 的骨架(serve.c 第 4221 行起):分配 server_baton_t/repository_t 与 client_info → 发 greeting(见 2.2)→ 读客户端 response,校验版本 =2 且宣称 edit-pipeline → find_repos(client_url, params->root, vhost, read_only, cfg, ...) 找仓库 → 检查"是否根本不允许访问":

      if (b->repository->anon_access == NO_ACCESS
          && (b->repository->auth_access == NO_ACCESS
              || (!b->client_info->tunnel_user && !b->repository->pwdb
                  && !b->repository->use_sasl)))

条件成立("匿名不允许 + 认证也不允许或根本没配任何认证手段")就报 SVN_ERR_RA_NOT_AUTHORIZED "No access allowed to this repository" → 否则 auth_request(..., READ_ACCESS, FALSE) 后若 current_access(b) == NO_ACCESS 则报"未获授权" → svn_fs_get_uuid → 发 repos-info(带仓库能力,见 2.5.3)→ 记运营日志 log_command(... "open %d cap=(%s) %s %s %s", ...) → 设置编辑器 shim、进入主命令循环。

auth_request()(第 729 行)是分派器:#ifdef SVN_HAVE_SASL 下若 b->repository->use_sasl 就走 cyrus_auth_request(),否则(及未启用 SASL 时)走内建 internal_auth_request()(第 695 行)。后者先发 auth-request,再循环 read_tuple(conn, pool, "w(?c)", &mech, &mecharg) 并调用 auth(...),直到成功:

      if (!*mech) break;   /* 客户端发空机制名 = 放弃认证(decline authentication) */

注意:循环结束时 success 可能仍为 FALSE,调用方再用 current_access() 判断最终权限。

authn 与 authz 是两个层次:authn(谁是用户)由上述机制决定,结果写入 b->client_info->user;authz(该用户能读写哪些路径)由 authz_check_access() 完成,规则来自 authz-db(svnserve)或 AuthzSVNAccessFile(httpd)。serve.c 在报告开始时把 authz 回调传给 svn_repos_begin_report3()(第 1010 行)——..., editor, edit_baton, authz_check_access_cb_func(b), &ab, svn_ra_svn_zero_copy_limit(conn), pool);而 authz_check_access_cb_func()(第 512 行)在仓库没配 authzdb 时返回 NULL——表示"不做路径级访问控制",与手页里"不指定 authz-db 则不做路径级访问控制"完全一致。

4.8 ra_svn 客户端结构

subversion/libsvn_ra_svn/ 的职责划分(Glob 实证):

client.c          RA vtable 实现:open/reparent/get_latest_revnum/get_file/get_dir/
                  get_mergeinfo/do_update/do_switch/do_status/do_diff/…/get_commit_editor
marshal.c         报文编解码 + 命令/元组读写 + 能力表
editorp.c         把网络命令驱动成 svn_delta_editor_t(服务端/客户端共用的编辑器)
internal_auth.c   内建认证(ANONYMOUS/EXTERNAL/CRAM-MD5)
cram.c            CRAM-MD5 细节
cyrus_auth.c      Cyrus SASL 客户端认证
wrapped_sasl.c    SASL 封装
streams.c         socket/stream 抽象与超时
version.c         RA 模块版本信息
ra_svn.h          内部数据结构(session_baton_t 等)

client.c 里最值得注意的几条实现线索:

  • ra_svn_get_schemes()(第 807 行)返回 { "svn", NULL }——RA loader 只按 svn 前缀把 URL 分派给 ra_svn;svn+ssh:// 之所以也能命中,是因为 parse_tunnel() 是从 URL 文本里手工解析 +tunnel 的,而不是靠 scheme 表。
  • ra_svn_get_reporter()(第 331 行)在服务端不支持 depth 且请求深度不是 files/infinity 时,会套一个 svn_delta_depth_filter_editor:if ((depth != svn_depth_files) && (depth != svn_depth_infinity) && ! svn_ra_svn_has_capability(conn, SVN_RA_SVN_CAP_DEPTH)) { svn_delta_depth_filter_editor(&filter_editor, &filter_baton, editor, edit_baton, depth, *target != '\0', pool); editor = filter_editor; edit_baton = filter_baton; }。这是能力缺失时的客户端降级:服务端不懂深度,客户端自己过滤;注释解释了为什么 files/infinity 不必过滤——服务端本来就会发对的东西。
  • ra_svn_finish_report()(第 296 行)体现"报告→认证→编辑"的顺序:svn_ra_svn__write_cmd_finish_report() → handle_auth_request() → svn_ra_svn_drive_editor2() 反向驱动编辑器 → svn_ra_svn__read_cmd_response(b->conn, b->pool, "")。
  • handle_auth_request()(第 243 行)读 ( l c )(机制列表 + realm):if (mechlist->nelts == 0) return SVN_NO_ERROR; 否则 return DO_AUTH(sess, mechlist, realm, pool);。这条"空列表 = 跳过认证"的逻辑,与服务端 internal_auth_request() 发空机制列表的分支一一对应。

5. WebDAV / DeltaV:HTTP 侧的协议

5.1 为什么是 DeltaV

HTTP 侧的 Subversion 不是"把 svn 协议塞进 HTTP 体",而是站在 WebDAV(RFC 2518)与 DeltaV(RFC 3253)之上。DeltaV 定义了"版本控制仓库所需的资源模型",mod_dav_svn 把它映射到 SVN 的 FS 上:Baseline(基线)=一个 SVN 版本;Baseline Collection(基线集合)=某版本下的一棵子树("某 rev 下的某路径");Activity(活动)=一个待提交事务(对应 FS transaction);Working Resource(工作资源)=活动里的某个可写节点。代价是"啰嗦"——一次提交要发好几个请求;收益是"标准兼容"——普通 WebDAV 客户端(甚至 Windows Web Folders)也能访问仓库(配合 autoversioning)。1.7 之后引入的 HTTPv2 把这套 DeltaV 包袱大幅精简,只保留必要的自定义 REPORT 与几个"stub URI"。

5.2 HTTP 方法在 Subversion 语义下的用途

mod_dav_svn 通过注册 mod_dav 的 provider 钩子来接管方法处理(mod_dav_svn.c 第 1449 行):

  /* our provider */
  dav_register_provider(pconf, "svn", &provider);
  ...
  /* general request handler for methods which mod_dav DECLINEs. */
  ap_hook_handler(dav_svn__handler, NULL, NULL, APR_HOOK_LAST);

方法本身(OPTIONS/GET/HEAD/PUT/PROPFIND/PROPPATCH/MKACTIVITY/CHECKOUT/CHECKIN/MERGE/REPORT/MKCOL/COPY/MOVE/DELETE/LOCK/UNLOCK/POST)由 httpd + mod_dav 解析后分派给 provider 钩子。逐个说明它们在 Subversion 语义下的用途:

  • OPTIONS:能力协商的主入口。见 5.4 节,它返回一大串 DAV: 头与 SVN-* 头。

  • GET / HEAD:读文件内容 / 读元数据(httpd 对 HEAD 用同一 M_GET 方法号)。当 SVNListParentPath on 时,父路径资源只允许 GET/HEAD,其他方法返回 405 并显式设置 Allow: GET,HEAD(repos.c 第 2237–2252 行)。

  • PUT:写文件内容。DeltaV 提交里 PUT 打在工作资源 !svn/wrk/<activity>/<path> 上;HTTPv2 提交里 PUT 打在事务根 !svn/txr/<txn>/<path> 上;autoversioning 下 PUT 直接打在公开 URL 上即可自动提交(见 7.4)。

  • PROPFIND:读属性(含 live properties 与 dead properties)。repos.c 的注释反复说明用途——VCC(!svn/vcc/default)用于"读/写 revprops"、rev 资源(!svn/rev/NN)用于"读/写 revprops"、txn 资源(!svn/txn/NAME)用于"提交期间读/写 txnprops"。更新报告时 ra_serf 还会对每个新增节点用 PROPFIND 补取属性(send-all 模式则不必要)。

  • PROPPATCH:写属性。revprop 的修改就是通过 PROPPATCH 一个 !svn/rev/<rev> 资源完成的;提交时 PROPPATCH 一个 !svn/txn/<name> 资源来设置 txnprops(svn:log、svn:author、svn:date 等)。

  • MKACTIVITY:创建活动,对应 make_activity()(version.c 第 1208 行):在 FS 上建一个以 HEAD 为基础的事务(dav_svn__create_txn()),再把 activity-id → txn-name 的映射登记进 activities db(dav_svn__store_activity())。

  • CHECKOUT:按 DeltaV 语义,对 VCC 或版本资源做 CHECKOUT 会创建一个工作资源(绑定到一个活动)。mod_dav_svn 里报错信息很能说明约束(version.c 第 571、578 行):"CHECKOUT can only be performed on a ..."、"CHECKOUT cannot create an activity at this time. Use MKACTIVITY first"。

  • CHECKIN:把工作资源"签入",即提交事务,产生一个新版本(version.c dav_svn__checkin());报错信息里有 "CHECKIN called on non-working resource"、"A conflict occurred during the CHECKIN ..."。

  • ORDER/UNCHECKOUT:DeltaV 里还有 UNCHECKOUT,mod_dav_svn 报 "UNCHECKOUT called on non-working resource"。

  • MERGE:提交的最后一击。客户端把"活动里那些被改动的资源"通过 MERGE 请求提交,服务端生成新版本并用 dav_svn__merge_response()(merge.c)回一个 XML 响应。

  • REPORT:读操作的主力,承载自定义报告(update-report、log-report、replay-report……),详见 5.5。

  • MKCOL:在事务/工作资源上创建集合(目录)。repos.c 有专门的错误文本 "MKCOL called on regular resource, but ..."(第 4115 行),说明它只在特定资源类型上被接受。

  • COPY / MOVE:在事务里复制/移动节点,对应 SVN 的"拷贝(copyfrom)"语义。repos.c 为 COPY 专门把输入 Depth 头覆盖为 "0"(第 2221 行),避免"O(源大小) 的深度遍历"。

  • DELETE:删除节点;也用于中止活动/事务(客户端提交失败时对 !svn/act/<id> 或 !svn/txn/<name> 发 DELETE)。

  • LOCK / UNLOCK:实现 SVN 的锁定。mod_dav_svn 有专门的 lock.c;lock.c 里第 991 行提到 UNLOCK 处理很简单,第 998 行说 "unlock processing for the "UNLOCK" method is very simple"。

  • POST:HTTPv2 专属,用于在 me 资源上一次性创建事务,跳过 MKACTIVITY 那一整套。dav_svn.h 第 530 行的注释写得很清楚:"POST request handler. (Used by HTTP protocol v2 clients only.)";实现在 repos.c 的 dav_svn__method_post()(第 4953 行)。

5.3 !svn/ 私有资源:从 DeltaV 到 HTTPv2

mod_dav_svn 把 Subversion 的"伪资源(pseudo resources)"放在一个可配置的特殊命名空间下,默认是 !svn。dav_svn.h 的注释(第 367–392 行)解释了这个设计:"SVN needs to create many types of 'pseudo resources' ... The special URI is configurable on a per-server basis and defaults to "!svn"."

私有资源类型定义在 enum dav_svn_private_restype(dav_svn.h 第 165 行),逐条对应其 URI:

enum dav_svn_private_restype {
  DAV_SVN_RESTYPE_UNSET,

  DAV_SVN_RESTYPE_ROOT_COLLECTION,      /* .../!svn/     */
  DAV_SVN_RESTYPE_VER_COLLECTION,       /* .../!svn/ver/ */
  DAV_SVN_RESTYPE_HIS_COLLECTION,       /* .../!svn/his/ */
  DAV_SVN_RESTYPE_WRK_COLLECTION,       /* .../!svn/wrk/ */
  DAV_SVN_RESTYPE_ACT_COLLECTION,       /* .../!svn/act/ */
  DAV_SVN_RESTYPE_VCC_COLLECTION,       /* .../!svn/vcc/ */
  DAV_SVN_RESTYPE_BC_COLLECTION,        /* .../!svn/bc/  */
  DAV_SVN_RESTYPE_BLN_COLLECTION,       /* .../!svn/bln/ */
  DAV_SVN_RESTYPE_WBL_COLLECTION,       /* .../!svn/wbl/ */
  DAV_SVN_RESTYPE_VCC,                  /* .../!svn/vcc/NAME */
  DAV_SVN_RESTYPE_PARENTPATH_COLLECTION,/* see SVNParentPath directive */

  /* new types in HTTP protocol v2: */
  DAV_SVN_RESTYPE_ME,                   /* .../!svn/me   */
  DAV_SVN_RESTYPE_REV_COLLECTION,       /* .../!svn/rev/ */
  DAV_SVN_RESTYPE_REVROOT_COLLECTION,   /* .../!svn/rvr/ */
  DAV_SVN_RESTYPE_TXN_COLLECTION,       /* .../!svn/txn/ */
  DAV_SVN_RESTYPE_TXNROOT_COLLECTION    /* .../!svn/txr/ */
};

这些 URI 的实际解析表在 repos.c 第 661 行的 special_subdirs[]:

} special_subdirs[] =
{
  /* Our original delta-V-ish protocol uses all these: */
  { "ver", parse_version_uri, 1, TRUE, DAV_SVN_RESTYPE_VER_COLLECTION },
  { "his", parse_history_uri, 0, FALSE, DAV_SVN_RESTYPE_HIS_COLLECTION },
  { "wrk", parse_working_uri, 1, TRUE,  DAV_SVN_RESTYPE_WRK_COLLECTION },
  { "act", parse_activity_uri, 1, FALSE, DAV_SVN_RESTYPE_ACT_COLLECTION },
  { "vcc", parse_vcc_uri, 1, FALSE, DAV_SVN_RESTYPE_VCC_COLLECTION },
  { "bc", parse_baseline_coll_uri, 1, TRUE, DAV_SVN_RESTYPE_BC_COLLECTION },
  { "bln", parse_baseline_uri, 1, FALSE, DAV_SVN_RESTYPE_BLN_COLLECTION },
  { "wbl", parse_wrk_baseline_uri, 2, FALSE, DAV_SVN_RESTYPE_WBL_COLLECTION },

  /* The new v2 protocol uses these new 'stub' uris: */
  { "me",  parse_me_resource_uri, 0, FALSE, DAV_SVN_RESTYPE_ME },
  { "rev", parse_revstub_uri, 1, FALSE, DAV_SVN_RESTYPE_REV_COLLECTION },
  { "rvr", parse_revroot_uri, 1, TRUE, DAV_SVN_RESTYPE_REVROOT_COLLECTION },
  { "txn", parse_txnstub_uri, 1, FALSE, DAV_SVN_RESTYPE_TXN_COLLECTION},
  { "txr", parse_txnroot_uri, 1, TRUE, DAV_SVN_RESTYPE_TXNROOT_COLLECTION},
  { "vtxn", parse_vtxnstub_uri, 1, FALSE, DAV_SVN_RESTYPE_TXN_COLLECTION},
  { "vtxr", parse_vtxnroot_uri, 1, TRUE, DAV_SVN_RESTYPE_TXNROOT_COLLECTION},

  { NULL } /* sentinel */
};

逐条解释含义(含仓库代码注释):

子目录含义典型用途/方法
ver版本资源。/!svn/ver/REV/PATH 表示"某版本下某个路径的版本资源"。PROPFIND/GET
his版本历史资源(version history)。PROPFIND
wrk工作资源集合。/!svn/wrk/<activity-id>/<path> 是活动里的可写节点。PUT/PROPPATCH/COPY/MOVE/DELETE
act活动集合。/!svn/act/<activity-id> 是活动本身。MKACTIVITY 创建;DELETE 中止
vcc版本受控配置。/!svn/vcc/default 是那个唯一的 VCC(DAV_SVN__DEFAULT_VCC_NAME 就是 "default")。PROPFIND/PROPPATCH(读写 revprops/最新版本)
bc基线集合。/!svn/bc/REV/PATH = "某版本某路径"。PROPFIND/GET
bln基线。/!svn/bln/REV = "某版本"。PROPFIND/PROPPATCH
wbl工作基线。/!svn/wbl/<activity-id>/REV 表示"活动的工作基线"。PROPFIND/PROPPATCH/CHECKOUT
meHTTPv2:代表"仓库本身",是自定义 REPORT 的发送目标(替代 VCC)。REPORT/POST
revHTTPv2:/!svn/rev/REV 代表"某个版本"。PROPFIND/PROPPATCH(读写 revprops,替代 bln/wbl)
rvrHTTPv2:/!svn/rvr/REV/PATH 表示"某版本内某个路径"。PROPFIND/GET(替代 bc)
txnHTTPv2:/!svn/txn/TXN_NAME 代表"某个未提交事务"。PROPFIND/PROPPATCH、DELETE 中止
txrHTTPv2:/!svn/txr/TXN_NAME/PATH 表示"某事务内某路径"。PUT/COPY/DELETE/MOVE(修改路径)
vtxnHTTPv2 的"客户端命名事务":/!svn/vtxn/TXN_NAME,允许客户端自带事务名。POST/PROPFIND/PROPPATCH
vtxrvtxn 的根:/!svn/vtxr/TXN_NAME/PATH。PUT/COPY/DELETE/MOVE

repos.c 的解析函数里都带着注释级的语义说明,例如:parse_me_resource_uri()(第 328 行)注释 "in HTTP protocol v2, this uri represents the repository itself, and is the place where custom REPORTs get sent to.(It replaces the older vcc uri form.)";parse_revstub_uri()(第 433 行)注释 "format: !svn/rev/REVISION … Clients perform PROPFIND and PROPPATCH against it to read and write revprops.(This uri replaces baseline (bln) and working baseline (wbl) forms.)";parse_txnstub_uri()(第 489 行)注释 "format: !svn/txn/TXN_NAME … Clients perform PROPFIND and PROPPATCH against it to read and write txnprops during a commit. They can also issue a DELETE against it to abort the txn.";parse_txnroot_uri()(第 535 行)注释 "format: !svn/txr/TXN_NAME/[PATH] … Clients perform PUT, COPY, DELETE, MOVE against it to modify the path."。

5.4 OPTIONS:能力协商的线上形态(硬示例)

HTTP 侧的"握手"就是 OPTIONS 请求。它的响应体里会带上 DAV: 头(为一串逗号分隔的能力令牌),以及一整套 SVN-* 头。构造逻辑在 version.c 的 get_vsn_options()(第 137 行)与 get_option()(第 174 行)。

get_vsn_options()(第 143–168 行)用一系列 apr_text_append() 往 DAV: 头里追加令牌:先两条 DeltaV 的 compliance 串 "version-control,checkout,working-resource" 与 "merge,baseline,activity,version-controlled-collection",随后逐个追加 SVN_DAV_NS_DAV_SVN_DEPTH、...LOG_REVPROPS、...ATOMIC_REVPROPS、...PARTIAL_REPLAY、...INHERITED_PROPS、...INLINE_PROPS、...REVERSE_FILE_REVS、...LIST、...MERGEINFO。

这些宏的值来自 include/svn_dav.h(SVN_DAV_PROP_NS_DAV 前缀 = "http://subversion.tigris.org/xmlns/dav/"),因此 SVN_DAV_NS_DAV_SVN_DEPTH 展开成 http://subversion.tigris.org/xmlns/dav/svn/depth,SVN_DAV_NS_DAV_SVN_LIST 展开成 .../svn/list,以此类推。

get_option() 里有两块关键内容:

(一)HTTPv2 的 stub URI 头(当 repos->v2_protocol 为真时,第 285–355 行)——它把一堆"资源与资源 stub 的 URL"塞进响应头,客户端据此就能"naively build addressable resources"(原注释语):

      apr_table_addn(r->headers_out, "DAV",
                     SVN_DAV_NS_DAV_SVN_REPLAY_REV_RESOURCE);
      apr_table_set(r->headers_out, SVN_DAV_ROOT_URI_HEADER, repos_root_uri);
      apr_table_set(r->headers_out, SVN_DAV_ME_RESOURCE_HEADER,
                    apr_pstrcat(r->pool, repos_root_uri, "/",
                                dav_svn__get_me_resource_uri(r), SVN_VA_NULL));
      apr_table_set(r->headers_out, SVN_DAV_REV_ROOT_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_REV_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_TXN_ROOT_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_TXN_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_VTXN_ROOT_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_VTXN_STUB_HEADER, ...);
      apr_table_set(r->headers_out, SVN_DAV_ALLOW_BULK_UPDATES,
                    bulk_upd_conf == CONF_BULKUPD_ON ? "On" :
                      bulk_upd_conf == CONF_BULKUPD_OFF ? "Off" : "Prefer");

这里的头名(include/svn_dav.h 逐个定义)是:SVN-Repository-Root(SVN_DAV_ROOT_URI_HEADER)、SVN-Me-Resource、SVN-Rev-Stub、SVN-Rev-Root-Stub、SVN-Txn-Stub、SVN-Txn-Root-Stub、SVN-VTxn-Stub、SVN-VTxn-Root-Stub、SVN-Allow-Bulk-Updates、SVN-Supported-Posts(SVN_DAV_SUPPORTED_POSTS_HEADER)、SVN-Repository-MergeInfo、SVN-Youngest-Rev、SVN-Repository-UUID。

(二)提交能力与 POST 类型(第 297–354 行):HTTPv2 按版本声明 POST 类型与提交能力——POST 类型 posts_versions[] = create-txn(1.7 起)、create-txn-with-props(1.8 起);提交能力 capabilities[] = svn/ephemeral-txnprops(1.8)、svn/svndiff1/svn/svndiff2/svn/put-result-checksum(1.10)。每条都带一个 min_version,用于"WebDAV 从属"场景(SVNMasterURI + SVNMasterVersion)下按主服务器版本过滤:主服务器太老就不宣称该能力。

一个概念化的 OPTIONS 响应(HTTPv2 开启时)大致长这样:

HTTP/1.1 200 OK
DAV: 1,2,version-control,checkout,working-resource
DAV: merge,baseline,activity,version-controlled-collection
DAV: http://subversion.tigris.org/xmlns/dav/svn/depth
DAV: .../svn/log-revprops          (另有 atomic-revprops / partial-replay /
DAV: .../svn/inline-props            inherited-props / inline-props /
DAV: .../svn/list                    reverse-file-revs / list / mergeinfo /
DAV: .../svn/replay-rev-resource     replay-rev-resource 等)
SVN-Repository-Root: http://host/svn/repo
SVN-Me-Resource:     http://host/svn/repo/!svn/me
SVN-Rev-Stub:        http://host/svn/repo/!svn/rev
SVN-Rev-Root-Stub:   http://host/svn/repo/!svn/rvr
SVN-Txn-Stub:        http://host/svn/repo/!svn/txn
SVN-Txn-Root-Stub:   http://host/svn/repo/!svn/txr
SVN-VTxn-Stub:       http://host/svn/repo/!svn/vtxn
SVN-VTxn-Root-Stub:  http://host/svn/repo/!svn/vtxr
SVN-Allow-Bulk-Updates: On
SVN-Supported-Posts: create-txn
SVN-Supported-Posts: create-txn-with-props
SVN-Repository-MergeInfo: yes
SVN-Youngest-Rev: 42
SVN-Repository-UUID: <uuid>

(DAV: 1,2 里的 1,2 是 DeltaV 的 compliance class;SVN-Youngest-Rev/SVN-Repository-UUID 由 get_option() 在 repos->fs 非空时设置。)

客户端侧解析在 libsvn_ra_serf/options.c 的 options_headers_iterator_callback()(第 256 行起),它对键做三层判断:以 D 开头是 DAV: 头(在外层先把 DAV 头的值拆成能力列表);以 S/V/N 开头(即 SVN-*)则说明服务端支持 HTTPv2——此时若 session->supported_posts 尚未建立,就先把 create-txn 塞进去("既然它说 HTTPv2,那必然支持 create-txn")。随后按头名把值存进会话:SVN_DAV_ME_RESOURCE_HEADER → session->me_resource = apr_pstrdup(...)、SVN_DAV_REV_STUB_HEADER → session->rev_stub、SVN_DAV_ALLOW_BULK_UPDATES → session->server_allows_bulk、SVN_DAV_SUPPORTED_POSTS_HEADER → 按逗号拆分逐个塞进 supported_posts、SVN_DAV_REPOSITORY_MERGEINFO → 依 yes/no 设置 capabilities 表里的 mergeinfo。

关键判据:客户端判断"服务端是否支持 HTTPv2"的依据就是有没有拿到 SVN-Me-Resource 头(ra_serf.h 第 279 行):

#define SVN_RA_SERF__HAVE_HTTPV2_SUPPORT(sess) ((sess)->me_resource != NULL)

若服务端没返回这个头(例如是 1.6 或更早的 mod_dav_svn,或管理员用 SVNAdvertiseV2Protocol off 关掉了),客户端就退回旧 DeltaV 流程——这就是 7.5 节讲的"能力缺失时的降级"。

5.5 REPORT 全集

DAV 的 REPORT 是 Subversion 自定义读操作的载体。全部可用报告名在 dav_svn.h 第 695 行的 dav_svn__reports_list[]:

static const dav_report_elem dav_svn__reports_list[] = {
  { SVN_XML_NAMESPACE, "update-report" },
  { SVN_XML_NAMESPACE, "log-report" },
  { SVN_XML_NAMESPACE, "dated-rev-report" },
  { SVN_XML_NAMESPACE, "get-locations" },
  { SVN_XML_NAMESPACE, "get-location-segments" },
  { SVN_XML_NAMESPACE, "file-revs-report" },
  { SVN_XML_NAMESPACE, "get-locks-report" },
  { SVN_XML_NAMESPACE, "replay-report" },
  { SVN_XML_NAMESPACE, "get-deleted-rev-report" },
  { SVN_XML_NAMESPACE, SVN_DAV__MERGEINFO_REPORT },
  { SVN_XML_NAMESPACE, SVN_DAV__INHERITED_PROPS_REPORT },
  { SVN_XML_NAMESPACE, "list-report" },
  { NULL, NULL },
};

共 12 个报告(最后一项是哨兵)。它们的名字都在 SVN_XML_NAMESPACE(即 svn:)命名空间里。avail_reports()(version.c 第 1098 行)在资源类型为 DAV_RESOURCE_TYPE_REGULAR 时返回这个表(*reports = dav_svn__reports_list;),否则返回 NULL。

服务端分派在 deliver_report()(version.c 第 1119 行):按 doc->root->name 逐名 strcmp,命中就调用对应 handler——"update-report"→dav_svn__update_report()、"log-report"→dav_svn__log_report()、……SVN_DAV__MERGEINFO_REPORT→dav_svn__get_mergeinfo_report()、"get-deleted-rev-report"→dav_svn__get_deleted_rev_report()、SVN_DAV__INHERITED_PROPS_REPORT→dav_svn__get_inherited_props_report()、"list-report"→dav_svn__list_report()(源码里留了一句 /* NOTE: if you add a report, don't forget to add it to the dav_svn__reports_list[] array. */)。都不匹配则返回 dav_svn__new_error_svn(..., HTTP_NOT_IMPLEMENTED, SVN_ERR_UNSUPPORTED_FEATURE, 0, "The requested report is unknown")。

逐条对照表(名称对应语义与对应的 svn 协议命令):

REPORT 名语义对应的 ra_svn 命令
update-report更新/切换的报告:客户端描述本地状态,服务端反向驱动编辑器。是 svn update/svn checkout/svn switch 的核心。update / switch / status / diff
log-report取历史日志(含 changed paths)。log
dated-rev-report取"某时间对应的版本号"。get-dated-rev
get-locations取某路径在一组版本里的位置。get-locations
get-location-segments取某路径的位置区间(segments)。get-location-segments
file-revs-report取文件的逐版本历史(file-revs)。get-file-revs
get-locks-report取某个路径(含深度)下的锁列表。get-locks
replay-report重放一个版本的编辑操作。replay / replay-range
get-deleted-rev-report取"某路径在某范围内最后被删除的版本"。get-deleted-rev
mergeinfo-report取合并信息(mergeinfo)。名字来自 SVN_DAV__MERGEINFO_REPORT。get-mergeinfo
inherited-props-report取继承属性。名字来自 SVN_DAV__INHERITED_PROPS_REPORT。get-iprops
list-report列目录(带深度与字段选择)。list

其中两个名字带 SVN_DAV__ 宏,是因为它们也被客户端共享——定义在 include/private/svn_dav_protocol.h:

/** Names for the custom HTTP REPORTs understood by mod_dav_svn, sans
    namespace. */
#define SVN_DAV__MERGEINFO_REPORT "mergeinfo-report"
#define SVN_DAV__INHERITED_PROPS_REPORT "inherited-props-report"

同一个头文件还定义了这些 REPORT 的XML 子元素名(用来给第三方/客户端共享元素名):

#define SVN_DAV__CREATIONDATE "creationdate"
#define SVN_DAV__MERGEINFO_ITEM "mergeinfo-item"
#define SVN_DAV__MERGEINFO_PATH "mergeinfo-path"
#define SVN_DAV__MERGEINFO_INFO "mergeinfo-info"
#define SVN_DAV__PATH "path"
#define SVN_DAV__INHERIT "inherit"
#define SVN_DAV__REVISION "revision"
#define SVN_DAV__INCLUDE_DESCENDANTS "include-descendants"
#define SVN_DAV__VERSION_NAME "version-name"
#define SVN_DAV__IPROP_ITEM "iprop-item"
#define SVN_DAV__IPROP_PATH "iprop-path"
#define SVN_DAV__IPROP_PROPNAME "iprop-propname"
#define SVN_DAV__IPROP_PROPVAL "iprop-propval"

以及修改 revprop 时 PROPPATCH 扩展用的两个属性名(用于 svn_ra_change_rev_prop2() 的"老值原子性"):

#define SVN_DAV__OLD_VALUE "old-value"
#define SVN_DAV__OLD_VALUE__ABSENT "absent"

最后这个 old-value = absent 是关键语义:它表示"我要求这次修改发生的前提是该属性此前不存在"(来自 svn_ra_change_rev_prop2() 的"previous-value 为 NULL"情形),对应 SVN_DAV_NS_DAV_SVN_ATOMIC_REVPROPS 能力。


6. WebDAV 提交流程与 ra_serf 结构

6.1 DeltaV(v1)提交流程(硬示例)

HTTPv1(旧 DeltaV)提交一次"新增一个文件并设置属性",需要这些请求(方法、URL、关键头部/体):

① OPTIONS /svn/repo HTTP/1.1
   (协商能力;拿到 DAV: 与 SVN-* 头;若没有 SVN-Me-Resource,说明是 v1 流程)

② PROPFIND /svn/repo/!svn/vcc/default
   Body: <D:propfind><D:prop><D:activity-collection-set/></D:prop></D:propfind>
   (取活动集合的位置;这一步与 MKACTIVITY 一起构成"提交前的准备")

③ MKACTIVITY /svn/repo/!svn/act/<client-generated-uuid> HTTP/1.1
   (服务端在 HEAD 基础上创建事务,并记录 activity-id -> txn-name 映射)

④ PROPFIND /svn/repo/!svn/vcc/default  (或 CHECKOUT)
   目的:获得工作基线 /!svn/wbl/<activity-id>/<base-rev>

⑤ PUT /svn/repo/!svn/wrk/<activity-id>/trunk/README HTTP/1.1
   Content-Type: application/vnd.svn-svndiff   (或纯文本,视 is_svndiff)
   X-SVN-Base-Fulltext-MD5: <hex>             (可选,base 校验)
   X-SVN-Result-Fulltext-MD5: <hex>           (可选,结果校验)
   Body: <svndiff 流>

⑥ PROPPATCH /svn/repo/!svn/wrk/<activity-id>/trunk/README
   Body: 设置 svn:mime-type、自定义属性等

⑦ MERGE /svn/repo/!svn/act/<activity-id> HTTP/1.1
   Content-Type: text/xml
   Body: <S:merge xmlns:S="svn:"><S:source>...</S:source>...</S:merge>
   响应: 一个 DAV 多状态 XML(dav_svn__merge_response 生成),含新版本号等

⑧ DELETE /svn/repo/!svn/act/<activity-id> HTTP/1.1
   期望: 204 No Content(清理活动)

要点:

  • 方法语义分工:MKACTIVITY 建事务、PROPFIND/CHECKOUT 取工作基线、PUT 写内容、PROPPATCH 写属性、MERGE 才真正"提交成新版本"、DELETE 清理。
  • !svn/act/<uuid> 里的 uuid 由客户端生成;服务端把它映射到一个 FS 事务名(dav_svn__store_activity())。
  • Content-Type: application/vnd.svn-svndiff(SVN_SVNDIFF_MIME_TYPE)告诉服务端"体是 svndiff 增量而非完整正文";repos.c 里据此设 is_svndiff(第 2296 行):
    comb->priv.is_svndiff =
      ct != NULL
      && strcmp(ct, SVN_SVNDIFF_MIME_TYPE) == 0;
  • 头 X-SVN-VR-Base(SVN_DAV_DELTA_BASE_HEADER)用于"GET 时计算 delta 的基准";X-SVN-Options(SVN_DAV_OPTIONS_HEADER)承载 no-merge-response/lock-break/lock-steal/release-locks/keep-locks 等选项(include/svn_dav.h 第 71–82 行)。
  • MERGE 响应由 dav_svn__merge_response() 生成(merge.c),XML 里含 <D:response>、<D:href>、<D:checked-in> 等(merge.c 第 88–105 行的 send_response() 可见)。

6.2 HTTPv2 提交流程(硬示例)

HTTPv2 把上面 8 步缩成 4 步左右:

① OPTIONS /svn/repo  (拿到 SVN-Me-Resource、SVN-Txn-Stub、SVN-Txn-Root-Stub、
                        SVN-Supported-Posts 等)

② POST /svn/repo/!svn/me HTTP/1.1
   Content-Type: application/vnd.svn-skel       (SVN_SKEL_MIME_TYPE)
   Body: ( create-txn )                          (或 ( create-txn-with-props (svn:log..) ))
   响应头: SVN-Txn-Name: <txn>                   (若客户端带了 SVN-VTxn-Name: <uuid>,则回 SVN-VTxn-Name)

③ PUT /svn/repo/!svn/txr/<txn>/trunk/README HTTP/1.1
   Content-Type: application/vnd.svn-svndiff
   Body: <svndiff 流>
   (新增/修改节点;每个文件一次 PUT)

④ PROPPATCH /svn/repo/!svn/txn/<txn>            (设置 txnprops,如 svn:log/svn:author/svn:date)

⑤ MERGE /svn/repo/!svn/txn/<txn>                (提交事务,产生新版本)

(失败时:DELETE /svn/repo/!svn/txn/<txn> 中止事务)

为什么 POST 体是 skel? 因为 me 资源上的 POST 用 skel 编码(private/svn_skel.h)承载请求,而不是 XML。commit.c 的 create_txn_post_body()(第 1101 行)正是这样构造的:

  request_skel = svn_skel__make_empty_list(pool);
  if (revprops)
    {
      svn_skel_t *proplist_skel;
      SVN_ERR(svn_skel__unparse_proplist(&proplist_skel, revprops, pool));
      svn_skel__prepend(proplist_skel, request_skel);
      svn_skel__prepend_str("create-txn-with-props", request_skel, pool);
      skel_str = svn_skel__unparse(request_skel, pool);
      *body_bkt = SERF_BUCKET_SIMPLE_STRING(skel_str->data, alloc);
    }
  else
    {
      *body_bkt = SERF_BUCKET_SIMPLE_STRING("( create-txn )", alloc);
    }

响应处理 post_response_handler()/post_headers_iterator_callback()(第 1158、1201 行)从响应头里取事务名并据此拼 URL:拿到 SVN_DAV_TXN_NAME_HEADER 就用 sess->txn_stub/sess->txn_root_stub 加上该值拼出 txn_url/txn_root_url;拿到 SVN_DAV_VTXN_NAME_HEADER 则用 vtxn_stub/vtxn_root_stub 拼。

open_root()(第 1222 行)负责分支:若 SVN_RA_SERF__HAVE_HTTPV2_SUPPORT 走 POST,否则走旧的"创建活动"路径。提交收尾在 close_edit()(第 2241 行):merge_target = ctx->activity_url ? ctx->activity_url : ctx->txn_url;,随后 svn_ra_serf__run_merge(&commit_info, ctx->session, merge_target, ctx->lock_tokens, ctx->keep_locks, pool, pool);若是 v1 流程(ctx->activity_url 非空)再对活动发 DELETE、期望 204。即 HTTPv2 用 ctx->txn_url 作 MERGE 目标,v1 用 ctx->activity_url。

abort_edit()(第 2296 行)对二者之一发 DELETE,并容忍 204/403/404(源码注释:"204 if deleted, 403 if DELETE was forbidden (indicates MKACTIVITY was forbidden too), 404 if the activity wasn't found."),其它状态码才报 svn_ra_serf__unexpected_status()。

6.3 ra_serf 结构

subversion/libsvn_ra_serf/ 的文件(Glob 实证)按职责分为:

核心:serf.c/ra_serf.h(会话与 vtable、OPTIONS 后的会话状态、连接池)、options.c(OPTIONS 请求与响应头解析)、util.c(通用 handler 与请求/响应基础设施)、xml.c/multistatus.c(XML 与 DAV 多状态解析)、update.c(update-report 构造与响应 + bulk updates 决策)、commit.c(提交编辑器 POST/PUT/PROPPATCH/MERGE/DELETE)、merge.c、request_body.c(请求体流,svndiff 分块,in-memory 或临时文件)、blncache.c(baseline 缓存)、sb_bucket.c/eagain_bucket.c/stream_bucket.c(自定义 serf bucket)。读操作各一文件:replay.c(replay-report)、log.c(log-report)、getlocations.c(get-locations)、getlocationsegments.c(get-location-segments)、getdate.c(dated-rev-report)、get_deleted_rev.c(get-deleted-rev-report);锁相关 getlocks.c/get_lock.c/lock.c;其余 blame.c/list.c/property.c/inherited_props.c/stat.c/get_file.c。

me_resource 是否为 NULL,是整个客户端走 v1 还是 v2 的唯一开关(见 5.4 节)。

6.4 X-SVN-Options 与校验和头

提交/更新时,SVN 客户端会带上自定义头(include/svn_dav.h):

  • X-SVN-Options: no-merge-response(SVN_DAV_OPTION_NO_MERGE_RESPONSE)——告诉服务端"MERGE 不用回完整响应体"(对应 CHANGES 里 ra_serf: Don't necessarily request full MERGE responses (r1806017 et al));
  • X-SVN-Options: lock-break|lock-steal|release-locks|keep-locks——控制锁行为;
  • X-SVN-Version-Name(SVN_DAV_VERSION_NAME_HEADER)——comb->priv.version_name 的来源(repos.c 第 2312 行);
  • X-SVN-Base-Fulltext-MD5 / X-SVN-Result-Fulltext-MD5——base/结果正文的 MD5 校验。

7. 认证、鉴权、autoversioning 与版本演进

7.1 认证(authn):谁在用 HTTP 访问

HTTP 侧的认证本身委托给 httpd,而不是 mod_dav_svn:

  • HTTP Basic:由 mod_auth_basic(+mod_authn_file/mod_authn_dbd 等)提供用户名/密码;
  • HTTP Digest:由 mod_auth_digest 提供挑战-响应认证,密码不明文上线(与 svn 协议的 CRAM-MD5 精神一致);
  • SSL 客户端证书:由 mod_ssl 的客户端证书校验提供(SSLVerifyClient 等指令),常与 SSLUserName 配合把证书主体映射成用户名;
  • 无论哪种,最终都由 httpd 把用户名写入 r->user。

mod_dav_svn 只消费 r->user。证据在 dav_svn.h 的 authz 接口注释里——dav_svn__allow_read() 的说明是"Return TRUE iff the current user (as determined by Apache's authentication system) has permission to read PATH",而 dav_svn_repos.username 字段就是 r->user 的拷贝(repos.c 第 2384 行 repos->username = r->user;)。

1.15 测试侧有一处相关改动(CHANGES):* tests: Allow testing ra_serf using Digest authentication (r1926790)——让测试框架能用 Digest 认证跑 ra_serf,是对"HTTP 认证路径"的测试补强。

SSL 客户端证书的意义在于:SVNPathAuthz 之外,还可以让证书主体作为身份参与 authz 判定;在只需要"证明你是谁、不需要密码"的内网场景很常见。

7.2 鉴权(authz):能读写哪些路径

HTTP 侧的路径级鉴权由 mod_authz_svn 提供,指令定义在 mod_authz_svn/mod_authz_svn.c:

  AP_INIT_TAKE1("AuthzSVNAccessFile", AuthzSVNAccessFile_cmd, ...);
  AP_INIT_TAKE1("AuthzSVNReposRelativeAccessFile", AuthzSVNReposRelativeAccessFile_cmd, ...);
  AP_INIT_FLAG("AuthzSVNNoAuthWhenAnonymousAllowed", ap_set_flag_slot, ...);

三个指令的语义(从源码与 mod_authz_svn/INSTALL 可见):

  • AuthzSVNAccessFile path:指定 authz 规则文件。path 可以是磁盘路径,也可以是 file:///path/to/repos/authz 或仓库相对 URL ^/authz(install 文档里确实出现这三种写法)。
  • AuthzSVNReposRelativeAccessFile filename:把规则文件放到"每个仓库自己的 conf 目录"下,实现按仓库的 authz(install 文档特别注明:用这个指令时,文件里不能含 group 定义)。
  • AuthzSVNNoAuthWhenAnonymousAllowed:当匿名用户被允许访问时不再要求认证。

这两个指令不能同时指定——源码里两处都返回错误 "AuthzSVNAccessFile and AuthzSVNReposRelativeAccessFile ..."(第 169、188 行)。

mod_dav_svn 内部把请求交给 subrequest 或直接查 authz,取决于 SVNPathAuthz 指令(on/off/bypass,见 mod_dav_svn.c 的 SVNPathAuthz_cmd,第 444 行)。bypass 走 pathauthz_bypass_func(第 455 行从 provider 表里取 AUTHZ_SVN__SUBREQ_BYPASS_PROV_*)。而 mod_dav_svn 侧的读取判定入口是 dav_svn__allow_read() / dav_svn__allow_read_resource() / dav_svn__allow_list_repos()(dav_svn.h 第 798、812、827 行),以及给报告用的 dav_svn__authz_read_func()。

7.3 仓库定位:SVNPath / SVNParentPath / DAVBasePath

这三个指令决定"URL 到底映射到磁盘上哪个仓库":

  • SVNPath:单个仓库的磁盘路径(mod_dav_svn.c 第 490 行 SVNPath_cmd);
  • SVNParentPath:一组仓库的父目录。此时 URL 会比 Location 多一个组件(仓库名),dav_svn_get_repos_path2() 用 svn_dirent_join(fs_parent_path, repos_name) 拼出真实路径(第 758 行);
  • 两者不能同时指定(源码里两处报错:"SVNPath cannot be defined at same time as SVNParentPath.",第 496 行)。

1.15 的改动(r1926683) 与 DAVBasePath 有关,CHANGES 写作 * mod_dav_svn: Use mod_dav's DAVBasePath setting to determine the repository root path (r1926683)。代码实证在 dav_svn__get_root_dir()(mod_dav_svn.c 第 780 行):

#if AP_MODULE_MAGIC_AT_LEAST(20120211, 139)
  const char *base;
  /* Inherit the root path from mod_dav's DavBasePath iff configured
     where e.g. LocationMatch is used for the repos. */
  base = dav_get_base_path(r);
  if (base) return base;
#endif
  conf = ap_get_module_config(r->per_dir_config, &dav_svn_module);
  return conf->root_dir;

即:当用 LocationMatch 之类的正则 Location 配置仓库时,mod_dav_svn 现在会继承 mod_dav 的 DAVBasePath 作为仓库根路径,而不是只认 SVNPath 对应的 root_dir——修复了以前"用正则 Location 时根路径算错"的问题。

7.4 autoversioning

SVNAutoversioning on 允许普通 WebDAV 客户端(不带 SVN 语义)直接对自己的文件 PUT 并自动提交。dav_svn.h 为此定义了一个"共享 dav_svn_root 的池键"常量:#define DAV_SVN__AUTOVERSIONING_ACTIVITY "svn-autoversioning-activity";指令实现 SVNAutoversioning_cmd()(mod_dav_svn.c 第 389 行)把 conf->autoversioning 设为 on/off。开启后:

  • 对公开 URL 的 PUT/COPY/MOVE/DELETE 会被**自动检出(auto-checkout)**并落到一个共享的"自动版本化活动"上;
  • resource->info->auto_checked_out(dav_svn.h 第 290 行)标记这个状态;
  • can_be_activity()(version.c 第 1195 行)对 auto_checked_out 的资源返回 TRUE(return resource->info->auto_checked_out || (type == ACTIVITY && !exists);),从而允许它成为活动;
  • 提交时 dav_svn__attach_auto_revprops()(dav_svn.h 第 594 行)会给事务自动补上 svn:log 和一个"由 autoversioning 产生"的属性。

autoversioning 的价值是"零客户端依赖":操作系统自带的 WebDAV 挂载、Web Folders 都能直接读写仓库。

7.5 边界与演进:协商失败、认证失败、能力缺失时的降级

(一)版本协商失败。 svn 协议侧有三条硬失败路径(2.3 节):

  • 服务端要求的最低版本 > 客户端支持的(minver > 2)→ SVN_ERR_RA_SVN_BAD_VERSION("Server requires minimum version N");
  • 服务端最高版本 < 客户端支持的(maxver < 2)→ 同错误码("Server only supports versions up to N");
  • 双方都不支持版本 1(1.15 两端均只支持 2):老客户端(只支持 v1)连新服务端会直接失败。

HTTP 侧没有"协议版本号"这一层,取而代之的是 HTTPv2 的能力发现:客户端用 SVN-Me-Resource 头是否存在来判定。

(二)认证失败。 svn 协议侧:服务端在 auth() 里发 ( failure ( <msg> ) ),客户端 read_success() 转成 SVN_ERR_RA_NOT_AUTHORIZED;客户端若列表中没有任何它支持的机制,则报 SVN_ERR_RA_SVN_NO_MECHANISMS(internal_auth.c 第 120 行)。HTTP 侧:认证失败由 httpd 直接回 401 Unauthorized(Basic/Digest)或 TLS 握手失败(客户端证书)。

(三)能力缺失时的降级。 这是"向后兼容"设计最密集的地方:

  • depth 缺失 → 客户端套 svn_delta_depth_filter_editor 自行过滤深度(client.c 第 345 行);HTTP 侧对应的是用 HTTPv2/report 里显式发 <S:depth>(update.c 第 2745 行)。
  • mergeinfo 缺失 → 客户端不做合并跟踪相关优化;HTTP 侧若 SVN-Repository-MergeInfo: no,则清掉 mergeinfo 能力(options.c 第 343 行)。
  • atomic-revprops 缺失 → 退回到 change-rev-prop(非原子)。
  • absent-entries 缺失 → 不发 absent-dir/absent-file。
  • HTTPv2 缺失 → 退回 DeltaV v1(6.1);SVNAdvertiseV2Protocol off 让服务端不广播 v2 头,从而让客户端使用 v1。
  • svndiff2/1 缺失 → 退回 svndiff0(marshal.c 的 svn_ra_svn__svndiff_version(),第 269 行:只有双方都支持时才会用 1 或 2)。

(四)SVNAllowBulkUpdates。 该指令取值 on/off/prefer(mod_dav_svn.c 第 404 行),服务端用枚举 dav_svn__bulk_upd_conf { CONF_BULKUPD_DEFAULT, CONF_BULKUPD_ON, CONF_BULKUPD_OFF, CONF_BULKUPD_PREFER } 表示,并以 SVN-Allow-Bulk-Updates 头发给客户端(On/Off/Prefer,version.c 第 335 行)。客户端在 update.c 第 2620 行附近按"用户偏好 + 服务端偏好 + 能力"决定:若 sess->server_allows_bulk 为 "off" 则不用、"prefer" 则用、其它(含 "on")用默认(不用);若服务端没发这个头(1.8 之前),则看是否支持内联属性——支持就不用 bulk,不支持就用 bulk。

  • bulk updates("send-all"):update-report 请求体加 send-all="true",服务端直接内联所有内容与属性(少往返、体量大);
  • 非 bulk(skelta):请求体不发 send-all,另加 <S:include-props>yes</S:include-props> 让 1.8+ 服务端"对新增项内联属性",避免逐个 PROPFIND。

(五)SVNCompressionLevel。 mod_dav_svn 侧指令 SVNCompressionLevel(第 635 行 SVNCompressionLevel_cmd)取值必须在 SVN_DELTA_COMPRESSION_LEVEL_NONE .. SVN_DELTA_COMPRESSION_LEVEL_MAX 之间,否则报"is not a valid compression level"。svnserve 侧对应 -c/--compression(4.2 节):0 表示不压缩,此时服务端连 svndiff1/accepts-svndiff2 能力都不宣称(2.2 节),于是线上退回 svndiff0。这就是"压缩关掉 = 协议自动降级"的机制。

(六)1.15 网络层的具体修复(按 r 号串联,均出自 CHANGES):

  • r1929793 ra_serf: Fix segfault on unexpected server responses——ra_serf 处理非预期服务端响应时的段错误修复(响应路径健壮性);
  • r1878084 ra_serf: Optimize array filling for inherited properties——优化继承属性(inherited-props-report/get-iprops)的数组填充;
  • r1926683 mod_dav_svn: Use mod_dav's DAVBasePath ...——见 7.3;
  • r1930808 Guard experimental Serf logic with SVN__SERF_EXPERIMENTAL——用编译宏保护实验性 serf 逻辑(构建/开发者可见);
  • r1926790 tests: Allow testing ra_serf using Digest authentication——见 7.1;
  • r1908547, r1934784 svnserve: Add SIGTERM/SIGINT handling——见 4.4。

六条恰好覆盖"客户端健壮性、客户端性能、服务端路径解析、构建守卫、测试覆盖、服务端停机",可看作 1.15 在网络协议层"修修补补、不改协议本身"的缩影:协议版本仍是 2,HTTPv2 仍是可选增强,1.15 没有引入新的线上协议。

7.6 小结:两条链路对照

维度svn:// / svn+ssh://http:// / https://
规范来源libsvn_ra_svn/protocol(ABNF 文档)WebDAV/DeltaV RFC + svn_dav.h/svn_dav_protocol.h
传输裸 TCP 3690 / SSH 隧道HTTP/1.1(可 TLS)
握手greeting + response(版本 + 能力)OPTIONS(DAV: 头 + SVN-* 头)
认证ANONYMOUS/EXTERNAL/CRAM-MD5(或 SASL)httpd 的 Basic/Digest/客户端证书
鉴权svnserve.conf 的 anon-access/auth-access/authz-dbAuthzSVNAccessFile 等 + SVNPathAuthz
客户端libsvn_ra_svn/client.clibsvn_ra_serf/*
服务端svnservemod_dav_svn(Apache)
读操作主命令(get-file/get-dir/log/…)自定义 REPORT(update-report/log-report/…)
写操作commit → 编辑器命令集MKACTIVITY/PUT/PROPPATCH/MERGE(v1)或 POST/PUT/PROPPATCH/MERGE(v2)
能力缺失降级depth 过滤、svndiff 版本回退HTTPv2→DeltaV、bulk updates 决策、mergeinfo 降级
1.15 变更svnserve 信号处理DAVBasePath、ra_serf 健壮性/性能、Digest 测试、serf 守卫

读懂这张表,就抓住了 Subversion 网络层的全貌:同一个仓库语义,两套线上表达;能力协商与降级贯穿两端;1.15 只做加固与优化,不动协议骨架。


附:本章所有引用的文件路径、函数名与行号,均已在正文相应位置以 [名字](subversion-1.15.0/路径#L起-L止) 或文字形式标注,可逐个回溯核对。