- Published on
rclone 同步文件到 SFTP “InvalidUTF8” 问题 Troubleshooting
- Authors
- Name
- Wang Zhiwei
问题初步分析
rclone 版本 v1.64.2
同步命令:
rclone -vv copy s3:/bucket/sftp_sync_test sftp:/sftp_sync_test
同步任务详细日志:
2024/03/15 13:10:33 DEBUG : test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf: Need to transfer - File not found at Destination
2024/03/15 13:10:33 DEBUG : sftp://admin@azuressh2_sftp:22//sftp_sync_test: Waiting for checks to finish
2024/03/15 13:10:33 ERROR : test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf: Failed to copy: Update Create failed: sftp: "InvalidUTF8: The input supplied is invalid UTF-8." (SSH_FX_BAD_MESSAGE)
2024/03/15 13:10:33 DEBUG : test_20240315/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf: Size and modification time the same (differ by 0s, within tolerance 1s)
2024/03/15 13:10:33 DEBUG : test_20240315/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf: Unchanged skipping
2024/03/15 13:10:33 DEBUG : sftp://admin@azuressh2_sftp:22//sftp_sync_test: Waiting for transfers to finish
2024/03/15 13:10:33 INFO : test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会�.pobafay0.partial: Failed to remove failed partial copy: stat failed: sftp: "InvalidUTF8: The input supplied is invalid UTF-8." (SSH_FX_BAD_MESSAGE)
2024/03/15 13:10:33 ERROR : Attempt 3/3 failed with 1 errors and: Update Create failed: sftp: "InvalidUTF8: The input supplied is invalid UTF-8." (SSH_FX_BAD_MESSAGE)
同步失败文件:
test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf
在 Python 中对文件名进行编码测试,确认是否为非法 utf8 字符串。
In [1]: file = '(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会会议通知(0).pdf'
In [2]: file.encode('utf8')
Out[2]: b'\xef\xbc\x88\xe5\x8d\xb0\xe7\xab\xa0\xe6\x96\x87\xe4\xbb\xb6\xef\xbc\x89\xe5\xb9\xbf\xe4\xb8\x9c\xe7\x9c\x81\xe5\x99\xa8\xe5\xae\x98\xe5\x8c\xbb\xe5\xad\xa6\xe4\xb8\x8e\xe6\x8a\x80\xe6\x9c\xaf\xe5\xad\xa6\xe4\xbc\x9a\xe6\xb6\x88\xe5\x8c\x96\xe9\x81\x93\xe8\x82\xbf\xe7\x98\xa4\xe8\xa7\x84\xe8\x8c\x83\xe5\x8c\x96\xe8\xaf\x8a\xe7\x96\x97-\xe9\xa6\x96\xe5\xb1\x8a\xe4\xb8\xad\xe9\x9d\x92\xe5\xb9\xb4\xe4\xb8\x93\xe5\xae\xb6\xe4\xba\xa4\xe6\xb5\x81\xe4\xbc\x9a\xe4\xbc\x9a\xe8\xae\xae\xe9\x80\x9a\xe7\x9f\xa5(0).pdf'
测试结果看起来没有问题,文件名是合法的 utf8。
仔细观察日志,发现这样一行:
2024/03/15 13:10:33 INFO : test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会�.pobafay0.partial: Failed to remove failed partial copy: stat failed: sftp: "InvalidUTF8: The input supplied is invalid UTF-8." (SSH_FX_BAD_MESSAGE)
可以看到这里在操作一个奇怪的文件 “印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会�.pobafay0.partial”,我们来源文件里并没有这样一个文件,对比原始文件名,像是被重命名了,不是很理解这里的逻辑。
到 rclone 源码中去查找原因
下载 rclone 源码
git clone https://github.com/rclone/rclone.git
在源码里搜索 .partial
,看到在 fs/operations/operations.go 代码中有相关逻辑引用到。
跳转到代码实现部分,看起来在这里对文件名进行了截断并重命名,这里逻辑比较粗暴,直接取前 100 位字节,但是 utf8 编码中,一个汉字占用 3 个字节,很明显此处有些情况下很容易把合法 utf8 字符串结成非法的。
var (
inplace = true
remotePartial = remote
)
if !ci.Inplace && f.Features().Move != nil && f.Features().PartialUploads && !strings.HasSuffix(remote, ".rclonelink") {
// Avoid making the leaf name longer if it's already lengthy to avoid
// trouble with file name length limits.
suffix := "." + random.String(8) + ".partial"
base := path.Base(remotePartial)
if len(base) > 100 {
remotePartial = remotePartial[:len(remotePartial)-len(suffix)] + suffix
} else {
remotePartial += suffix
}
inplace = false
}
在后续代码中上传文件的操作也都是使用了定义在变量 remotePartial
中重新命名的文件名。
同样可以看到下面代码实现
// Used to remove a failed partial copy
//
// Returns whether the file was successfully removed or not
func removeFailedPartialCopy(ctx context.Context, f fs.Fs, remotePartial string) bool {
o, err := f.NewObject(ctx, remotePartial)
if errors.Is(err, fs.ErrorObjectNotFound) {
return true
} else if err != nil {
fs.Infof(remotePartial, "Failed to remove failed partial copy: %s", err)
return false
}
return removeFailedCopy(ctx, o)
}
对应日志记录
2024/03/15 13:10:33 INFO : test_2023225/(印章文件)广东省器官医学与技术学会消化道肿瘤规范化诊疗-首届中青年专家交流会�.pobafay0.partial: Failed to remove failed partial copy: stat failed: sftp: "InvalidUTF8: The input supplied is invalid UTF-8." (SSH_FX_BAD_MESSAGE)
在 rclone 最新 v1.66.x 版本的代码中,这部分逻辑进行了重构,增加了一个配置参数 PartialSuffix
,默认值是 .partial
,重命名文件名的代码实现放在了代码文件 fs/operations/copy.go 中。
// TruncateString s to n bytes.
//
// If s is valid UTF-8 then this may truncate to fewer than n bytes to
// make the returned string also valid UTF-8.
func TruncateString(s string, n int) string {
truncated := s[:n]
if !utf8.ValidString(s) {
// If input string wasn't valid UTF-8 then just return the truncation
return truncated
}
for len(truncated) > 0 {
if utf8.ValidString(truncated) {
return truncated
}
// Remove 1 byte until valid
truncated = truncated[:len(truncated)-1]
}
return truncated
}
// Check to see if we should be using a partial name and return the name for the copy and the inplace flag
func (c *copy) checkPartial() (remoteForCopy string, inplace bool, err error) {
remoteForCopy = c.remote
if c.ci.Inplace || c.dstFeatures.Move == nil || !c.dstFeatures.PartialUploads || strings.HasSuffix(c.remote, ".rclonelink") {
return remoteForCopy, true, nil
}
if len(c.ci.PartialSuffix) > 16 {
return remoteForCopy, true, fmt.Errorf("expecting length of --partial-suffix to be not greater than %d but got %d", 16, len(c.ci.PartialSuffix))
}
// Avoid making the leaf name longer if it's already lengthy to avoid
// trouble with file name length limits.
suffix := "." + random.String(8) + c.ci.PartialSuffix
base := path.Base(c.remoteForCopy)
if len(base) > 100 {
remoteForCopy = TruncateString(c.remoteForCopy, len(c.remoteForCopy)-len(suffix)) + suffix
} else {
remoteForCopy += suffix
}
return remoteForCopy, false, nil
}
可以看到在原先 v1.64.2 版本中粗暴的截断逻辑,增加了检测算法,以保证截断的字符同样是合法的 utf8 字符串。
不过,此处的实现也有一些问题,在判断文件名长度用来决定是否 truncate 时,检测的对象是 c.remoteForCopy
,copy
对象的 remoteForCopy
属性初始化时是空字符串,后续也没有赋值的逻辑,显然永远在检测一个空字符的长度,不符合逻辑,也许应该是检测 c.remote
。在目前截止的 1.66.x 最新版本中,这里的代码逻辑依旧如此。
我到 rclone 论坛发帖咨询,从项目作者那得到了确定,这是一个 bug。
最后
显然只要将 rclone 版本从 v1.64.2 升级到 v.1.66.x 版本就可以避免同步时的 InvalidUTF8
报错。