Skip to content
项目
群组
代码片段
帮助
正在加载...
帮助
为 GitLab 提交贡献
登录/注册
切换导航
H
h2database
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
分枝图
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
计划
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
分枝图
统计图
创建新议题
作业
提交
议题看板
打开侧边栏
Administrator
h2database
Commits
42af75c5
提交
42af75c5
authored
1月 02, 2013
作者:
Thomas Mueller
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
MVStore: encrypted stores are now supported.
上级
4ee69cc5
全部展开
隐藏空白字符变更
内嵌
并排
正在显示
8 个修改的文件
包含
600 行增加
和
11 行删除
+600
-11
changelog.html
h2/src/docsrc/html/changelog.html
+2
-0
mvstore.html
h2/src/docsrc/html/mvstore.html
+24
-1
DataUtils.java
h2/src/main/org/h2/mvstore/DataUtils.java
+13
-0
MVStore.java
h2/src/main/org/h2/mvstore/MVStore.java
+47
-10
SHA256.java
h2/src/main/org/h2/security/SHA256.java
+48
-0
FilePathCrypt2.java
h2/src/main/org/h2/store/fs/FilePathCrypt2.java
+355
-0
TestMVStore.java
h2/src/test/org/h2/test/store/TestMVStore.java
+49
-0
TestSecurity.java
h2/src/test/org/h2/test/unit/TestSecurity.java
+62
-0
没有找到文件。
h2/src/docsrc/html/changelog.html
浏览文件 @
42af75c5
...
@@ -33,6 +33,8 @@ Change Log
...
@@ -33,6 +33,8 @@ Change Log
</li><li>
New connection setting "DEFAULT_TABLE_ENGINE" to use a specific
</li><li>
New connection setting "DEFAULT_TABLE_ENGINE" to use a specific
table engine if none is set explicitly. This is to simplify testing
table engine if none is set explicitly. This is to simplify testing
the MVStore table engine.
the MVStore table engine.
</li><li>
MVStore: encrypted stores are now supported.
Only standardized algorithms are used: PBKDF2, SHA-256, XTS-AES, AES-128.
</li><li>
MVStore: improved API thanks to Simo Tripodi.
</li><li>
MVStore: improved API thanks to Simo Tripodi.
</li><li>
MVStore: maps can now be renamed.
</li><li>
MVStore: maps can now be renamed.
</li><li>
MVStore: store the file header also at the end of each chunk,
</li><li>
MVStore: store the file header also at the end of each chunk,
...
...
h2/src/docsrc/html/mvstore.html
浏览文件 @
42af75c5
...
@@ -43,7 +43,7 @@ But it can be also directly within an application, without using JDBC or SQL.
...
@@ -43,7 +43,7 @@ But it can be also directly within an application, without using JDBC or SQL.
</li><li>
Transactions (even if they are persisted) can be rolled back.
</li><li>
Transactions (even if they are persisted) can be rolled back.
</li><li>
The tool is very modular. It supports pluggable data types / serialization,
</li><li>
The tool is very modular. It supports pluggable data types / serialization,
pluggable map implementations (B-tree, R-tree, concurrent B-tree currently), BLOB storage,
pluggable map implementations (B-tree, R-tree, concurrent B-tree currently), BLOB storage,
and a file system abstraction to support encryption and
compressed read-only
files.
and a file system abstraction to support encryption and
zip
files.
</li></ul>
</li></ul>
<h2
id=
"example_code"
>
Example Code
</h2>
<h2
id=
"example_code"
>
Example Code
</h2>
...
@@ -325,6 +325,29 @@ new data is always appended at the end of the file.
...
@@ -325,6 +325,29 @@ new data is always appended at the end of the file.
Then, the file can be copied (the file handle is available to the application).
Then, the file can be copied (the file handle is available to the application).
</p>
</p>
<h3>
Encrypted Files
</h3>
<p>
Data can be encrypted as follows:
</p>
<pre>
MVStore s = new MVStore.Builder().
fileName(fileName).
encryptionKey("007".toCharArray()).
open();
</pre>
<p>
The same security algorithms are used as modern disk encryption software use.
The password char array is cleared after use,
to reduce the risk that the password is stolen
even if the attacker has access to the main memory.
The password is hashed using the PBKDF2 standard, using the SHA-256 hash algorithm.
The length of the salt is 256 bits, so that an attacker can not use a rainbow table.
The salt is generated using a cryptographically secure random number generator.
The number of PBKDF2 iterations is 10000, so that an attacker can not "brute force" the password.
The file itself is encrypted using the standardized disk encryption mode XTS-AES,
so that only little more than one AES-128 round per block is needed.
</p>
<h3>
Tools
</h3>
<h3>
Tools
</h3>
<p>
<p>
There is a tool (
<code>
MVStoreTool
</code>
) to dump the contents of a file.
There is a tool (
<code>
MVStoreTool
</code>
) to dump the contents of a file.
...
...
h2/src/main/org/h2/mvstore/DataUtils.java
浏览文件 @
42af75c5
...
@@ -512,6 +512,7 @@ public class DataUtils {
...
@@ -512,6 +512,7 @@ public class DataUtils {
for
(
int
i
=
0
,
size
=
s
.
length
();
i
<
size
;)
{
for
(
int
i
=
0
,
size
=
s
.
length
();
i
<
size
;)
{
int
startKey
=
i
;
int
startKey
=
i
;
i
=
s
.
indexOf
(
':'
,
i
);
i
=
s
.
indexOf
(
':'
,
i
);
checkArgument
(
i
>
0
,
"Not a map"
);
String
key
=
s
.
substring
(
startKey
,
i
++);
String
key
=
s
.
substring
(
startKey
,
i
++);
StringBuilder
buff
=
new
StringBuilder
();
StringBuilder
buff
=
new
StringBuilder
();
while
(
i
<
size
)
{
while
(
i
<
size
)
{
...
@@ -608,4 +609,16 @@ public class DataUtils {
...
@@ -608,4 +609,16 @@ public class DataUtils {
Constants
.
VERSION_MINOR
+
"."
+
Constants
.
BUILD_ID
+
"]"
;
Constants
.
VERSION_MINOR
+
"."
+
Constants
.
BUILD_ID
+
"]"
;
}
}
static
byte
[]
getPasswordBytes
(
char
[]
passwordChars
)
{
// using UTF-16
int
len
=
passwordChars
.
length
;
byte
[]
password
=
new
byte
[
len
*
2
];
for
(
int
i
=
0
;
i
<
len
;
i
++)
{
char
c
=
passwordChars
[
i
];
password
[
i
+
i
]
=
(
byte
)
(
c
>>>
8
);
password
[
i
+
i
+
1
]
=
(
byte
)
c
;
}
return
password
;
}
}
}
h2/src/main/org/h2/mvstore/MVStore.java
浏览文件 @
42af75c5
...
@@ -11,6 +11,7 @@ import java.nio.ByteBuffer;
...
@@ -11,6 +11,7 @@ import java.nio.ByteBuffer;
import
java.nio.channels.FileChannel
;
import
java.nio.channels.FileChannel
;
import
java.nio.channels.FileLock
;
import
java.nio.channels.FileLock
;
import
java.util.ArrayList
;
import
java.util.ArrayList
;
import
java.util.Arrays
;
import
java.util.BitSet
;
import
java.util.BitSet
;
import
java.util.Collections
;
import
java.util.Collections
;
import
java.util.Comparator
;
import
java.util.Comparator
;
...
@@ -23,6 +24,7 @@ import org.h2.mvstore.cache.CacheLongKeyLIRS;
...
@@ -23,6 +24,7 @@ import org.h2.mvstore.cache.CacheLongKeyLIRS;
import
org.h2.mvstore.cache.FilePathCache
;
import
org.h2.mvstore.cache.FilePathCache
;
import
org.h2.mvstore.type.StringDataType
;
import
org.h2.mvstore.type.StringDataType
;
import
org.h2.store.fs.FilePath
;
import
org.h2.store.fs.FilePath
;
import
org.h2.store.fs.FilePathCrypt2
;
import
org.h2.store.fs.FileUtils
;
import
org.h2.store.fs.FileUtils
;
import
org.h2.util.MathUtils
;
import
org.h2.util.MathUtils
;
import
org.h2.util.New
;
import
org.h2.util.New
;
...
@@ -41,8 +43,7 @@ H:3,...
...
@@ -41,8 +43,7 @@ H:3,...
TODO:
TODO:
- file system encryption: check standard
- file system encryption
- mvcc with multiple transactions
- mvcc with multiple transactions
- update checkstyle
- update checkstyle
- automated 'kill process' and 'power failure' test
- automated 'kill process' and 'power failure' test
...
@@ -115,6 +116,7 @@ public class MVStore {
...
@@ -115,6 +116,7 @@ public class MVStore {
private
static
final
int
FORMAT_READ
=
1
;
private
static
final
int
FORMAT_READ
=
1
;
private
final
String
fileName
;
private
final
String
fileName
;
private
final
char
[]
filePassword
;
private
int
pageSize
=
6
*
1024
;
private
int
pageSize
=
6
*
1024
;
...
@@ -185,8 +187,10 @@ public class MVStore {
...
@@ -185,8 +187,10 @@ public class MVStore {
int
mb
=
s
==
null
?
16
:
Integer
.
parseInt
(
s
.
toString
());
int
mb
=
s
==
null
?
16
:
Integer
.
parseInt
(
s
.
toString
());
cache
=
new
CacheLongKeyLIRS
<
Page
>(
cache
=
new
CacheLongKeyLIRS
<
Page
>(
mb
*
1024
*
1024
,
2048
,
16
,
mb
*
1024
*
1024
/
2048
*
2
/
100
);
mb
*
1024
*
1024
,
2048
,
16
,
mb
*
1024
*
1024
/
2048
*
2
/
100
);
filePassword
=
(
char
[])
config
.
get
(
"encrypt"
);
}
else
{
}
else
{
cache
=
null
;
cache
=
null
;
filePassword
=
null
;
}
}
}
}
...
@@ -368,11 +372,17 @@ public class MVStore {
...
@@ -368,11 +372,17 @@ public class MVStore {
return
;
return
;
}
}
FileUtils
.
createDirectories
(
FileUtils
.
getParent
(
fileName
));
FileUtils
.
createDirectories
(
FileUtils
.
getParent
(
fileName
));
if
(
readOnly
)
{
try
{
openFile
();
if
(
readOnly
)
{
}
else
if
(!
openFile
())
{
openFile
();
readOnly
=
true
;
}
else
if
(!
openFile
())
{
openFile
();
readOnly
=
true
;
openFile
();
}
}
finally
{
if
(
filePassword
!=
null
)
{
Arrays
.
fill
(
filePassword
,
(
char
)
0
);
}
}
}
}
}
...
@@ -391,7 +401,12 @@ public class MVStore {
...
@@ -391,7 +401,12 @@ public class MVStore {
if
(
f
.
exists
()
&&
!
f
.
canWrite
())
{
if
(
f
.
exists
()
&&
!
f
.
canWrite
())
{
readOnly
=
true
;
readOnly
=
true
;
}
}
file
=
FilePathCache
.
wrap
(
f
.
open
(
readOnly
?
"r"
:
"rw"
));
file
=
f
.
open
(
readOnly
?
"r"
:
"rw"
);
if
(
filePassword
!=
null
)
{
byte
[]
password
=
DataUtils
.
getPasswordBytes
(
filePassword
);
file
=
new
FilePathCrypt2
.
FileCrypt2
(
password
,
file
);
}
file
=
FilePathCache
.
wrap
(
file
);
if
(
readOnly
)
{
if
(
readOnly
)
{
fileLock
=
file
.
tryLock
(
0
,
Long
.
MAX_VALUE
,
true
);
fileLock
=
file
.
tryLock
(
0
,
Long
.
MAX_VALUE
,
true
);
if
(
fileLock
==
null
)
{
if
(
fileLock
==
null
)
{
...
@@ -433,7 +448,7 @@ public class MVStore {
...
@@ -433,7 +448,7 @@ public class MVStore {
}
}
}
catch
(
Exception
e
)
{
}
catch
(
Exception
e
)
{
try
{
try
{
close
();
close
(
false
);
}
catch
(
Exception
e2
)
{
}
catch
(
Exception
e2
)
{
// ignore
// ignore
}
}
...
@@ -443,6 +458,7 @@ public class MVStore {
...
@@ -443,6 +458,7 @@ public class MVStore {
return
true
;
return
true
;
}
}
private
void
readMeta
()
{
private
void
readMeta
()
{
Chunk
header
=
readChunkHeader
(
rootChunkStart
);
Chunk
header
=
readChunkHeader
(
rootChunkStart
);
lastChunkId
=
header
.
id
;
lastChunkId
=
header
.
id
;
...
@@ -545,10 +561,16 @@ public class MVStore {
...
@@ -545,10 +561,16 @@ public class MVStore {
* Close the file. Uncommitted changes are ignored, and all open maps are closed.
* Close the file. Uncommitted changes are ignored, and all open maps are closed.
*/
*/
public
void
close
()
{
public
void
close
()
{
close
(
true
);
}
private
void
close
(
boolean
shrinkIfPossible
)
{
closed
=
true
;
closed
=
true
;
if
(
file
!=
null
)
{
if
(
file
!=
null
)
{
try
{
try
{
shrinkFileIfPossible
(
0
);
if
(
shrinkIfPossible
)
{
shrinkFileIfPossible
(
0
);
}
log
(
"file close"
);
log
(
"file close"
);
if
(
fileLock
!=
null
)
{
if
(
fileLock
!=
null
)
{
fileLock
.
release
();
fileLock
.
release
();
...
@@ -1416,6 +1438,21 @@ public class MVStore {
...
@@ -1416,6 +1438,21 @@ public class MVStore {
return
set
(
"fileName"
,
fileName
);
return
set
(
"fileName"
,
fileName
);
}
}
/**
* Encrypt / decrypt the file using the given password. This method has
* no effect for in-memory stores. The password is passed as a char
* array so that it can be cleared as soon as possible. Please note
* there is still a small risk that password stays in memory (due to
* Java garbage collection). Also, the hashed encryption key is kept in
* memory as long as the file is open.
*
* @param password the password
* @return this
*/
public
Builder
encryptionKey
(
char
[]
password
)
{
return
set
(
"encrypt"
,
password
);
}
/**
/**
* Open the file in read-only mode. In this case, a shared lock will be
* Open the file in read-only mode. In this case, a shared lock will be
* acquired to ensure the file is not concurrently opened in write mode.
* acquired to ensure the file is not concurrently opened in write mode.
...
...
h2/src/main/org/h2/security/SHA256.java
浏览文件 @
42af75c5
...
@@ -77,6 +77,54 @@ public class SHA256 {
...
@@ -77,6 +77,54 @@ public class SHA256 {
return
getHash
(
buff
,
true
);
return
getHash
(
buff
,
true
);
}
}
public
static
byte
[]
getHMAC
(
byte
[]
key
,
byte
[]
message
)
{
int
blockSize
=
64
;
if
(
key
.
length
>
blockSize
)
{
key
=
getHash
(
key
,
false
);
}
if
(
key
.
length
<
blockSize
)
{
key
=
Arrays
.
copyOf
(
key
,
blockSize
);
}
byte
[]
iKeyPadMessage
=
new
byte
[
blockSize
+
message
.
length
];
Arrays
.
fill
(
iKeyPadMessage
,
0
,
blockSize
,
(
byte
)
0x36
);
xor
(
iKeyPadMessage
,
key
,
blockSize
);
System
.
arraycopy
(
message
,
0
,
iKeyPadMessage
,
blockSize
,
message
.
length
);
byte
[]
k
=
getHash
(
iKeyPadMessage
,
false
);
byte
[]
oKeyPad
=
new
byte
[
blockSize
+
k
.
length
];
Arrays
.
fill
(
oKeyPad
,
0
,
blockSize
,
(
byte
)
0x5c
);
xor
(
oKeyPad
,
key
,
blockSize
);
System
.
arraycopy
(
k
,
0
,
oKeyPad
,
blockSize
,
k
.
length
);
return
getHash
(
oKeyPad
,
false
);
}
private
static
void
xor
(
byte
[]
target
,
byte
[]
data
,
int
len
)
{
for
(
int
i
=
0
;
i
<
len
;
i
++)
{
target
[
i
]
^=
data
[
i
];
}
}
public
static
byte
[]
getPBKDF2
(
byte
[]
password
,
byte
[]
salt
,
int
iterations
,
int
len
)
{
byte
[]
result
=
new
byte
[
len
];
byte
[]
last
=
null
;
for
(
int
k
=
1
,
offset
=
0
;
offset
<
len
;
k
++,
offset
+=
32
)
{
for
(
int
i
=
0
;
i
<
iterations
;
i
++)
{
byte
[]
x
;
if
(
i
==
0
)
{
x
=
Arrays
.
copyOf
(
salt
,
salt
.
length
+
4
);
writeInt
(
x
,
salt
.
length
,
k
);
}
else
{
x
=
last
;
}
last
=
getHMAC
(
password
,
x
);
for
(
int
j
=
0
;
j
<
32
&&
j
+
offset
<
len
;
j
++)
{
result
[
j
+
offset
]
^=
last
[
j
];
}
}
}
Arrays
.
fill
(
password
,
(
byte
)
0
);
return
result
;
}
/**
/**
* Calculate the hash code for the given data.
* Calculate the hash code for the given data.
*
*
...
...
h2/src/main/org/h2/store/fs/FilePathCrypt2.java
0 → 100644
浏览文件 @
42af75c5
差异被折叠。
点击展开。
h2/src/test/org/h2/test/store/TestMVStore.java
浏览文件 @
42af75c5
...
@@ -40,6 +40,7 @@ public class TestMVStore extends TestBase {
...
@@ -40,6 +40,7 @@ public class TestMVStore extends TestBase {
public
void
test
()
throws
Exception
{
public
void
test
()
throws
Exception
{
FileUtils
.
deleteRecursive
(
getBaseDir
(),
true
);
FileUtils
.
deleteRecursive
(
getBaseDir
(),
true
);
testEncryptedFile
();
testFileFormatChange
();
testFileFormatChange
();
testRecreateMap
();
testRecreateMap
();
testRenameMapRollback
();
testRenameMapRollback
();
...
@@ -74,6 +75,54 @@ public class TestMVStore extends TestBase {
...
@@ -74,6 +75,54 @@ public class TestMVStore extends TestBase {
testSimple
();
testSimple
();
}
}
private
void
testEncryptedFile
()
{
String
fileName
=
getBaseDir
()
+
"/testEncryptedFile.h3"
;
FileUtils
.
delete
(
fileName
);
MVStore
s
;
MVMap
<
Integer
,
String
>
m
;
char
[]
passwordChars
=
"007"
.
toCharArray
();
s
=
new
MVStore
.
Builder
().
fileName
(
fileName
).
encryptionKey
(
passwordChars
).
open
();
assertEquals
(
0
,
passwordChars
[
0
]);
assertEquals
(
0
,
passwordChars
[
1
]);
assertEquals
(
0
,
passwordChars
[
2
]);
assertTrue
(
FileUtils
.
exists
(
fileName
));
m
=
s
.
openMap
(
"test"
);
m
.
put
(
1
,
"Hello"
);
assertEquals
(
"Hello"
,
m
.
get
(
1
));
s
.
store
();
s
.
close
();
passwordChars
=
"008"
.
toCharArray
();
try
{
s
=
new
MVStore
.
Builder
().
fileName
(
fileName
).
encryptionKey
(
passwordChars
).
open
();
fail
();
}
catch
(
IllegalStateException
e
)
{
assertTrue
(
e
.
getCause
()
!=
null
);
}
assertEquals
(
0
,
passwordChars
[
0
]);
assertEquals
(
0
,
passwordChars
[
1
]);
assertEquals
(
0
,
passwordChars
[
2
]);
passwordChars
=
"007"
.
toCharArray
();
s
=
new
MVStore
.
Builder
().
fileName
(
fileName
).
encryptionKey
(
passwordChars
).
open
();
assertEquals
(
0
,
passwordChars
[
0
]);
assertEquals
(
0
,
passwordChars
[
1
]);
assertEquals
(
0
,
passwordChars
[
2
]);
m
=
s
.
openMap
(
"test"
);
assertEquals
(
"Hello"
,
m
.
get
(
1
));
s
.
close
();
FileUtils
.
delete
(
fileName
);
assertFalse
(
FileUtils
.
exists
(
fileName
));
}
private
void
testFileFormatChange
()
{
private
void
testFileFormatChange
()
{
String
fileName
=
getBaseDir
()
+
"/testFileFormatChange.h3"
;
String
fileName
=
getBaseDir
()
+
"/testFileFormatChange.h3"
;
FileUtils
.
delete
(
fileName
);
FileUtils
.
delete
(
fileName
);
...
...
h2/src/test/org/h2/test/unit/TestSecurity.java
浏览文件 @
42af75c5
...
@@ -46,9 +46,71 @@ public class TestSecurity extends TestBase {
...
@@ -46,9 +46,71 @@ public class TestSecurity extends TestBase {
}
}
private
void
testSHA
()
{
private
void
testSHA
()
{
testPBKDF2
();
testHMAC
();
testOneSHA
();
testOneSHA
();
}
}
private
void
testPBKDF2
()
{
// test vectors from StackOverflow (PBKDF2-HMAC-SHA2)
assertEquals
(
"120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getPBKDF2
(
"password"
.
getBytes
(),
"salt"
.
getBytes
(),
1
,
32
)));
assertEquals
(
"ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getPBKDF2
(
"password"
.
getBytes
(),
"salt"
.
getBytes
(),
2
,
32
)));
assertEquals
(
"c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getPBKDF2
(
"password"
.
getBytes
(),
"salt"
.
getBytes
(),
4096
,
32
)));
// take a very long time to calculate
// assertEquals(
// "cf81c66fe8cfc04d1f31ecb65dab4089f7f179e89b3b0bcb17ad10e3ac6eba46",
// StringUtils.convertBytesToHex(
// SHA256.getPBKDF2(
// "password".getBytes(),
// "salt".getBytes(), 16777216, 32)));
assertEquals
(
"348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c4e2a1fb8dd53e1c635518c7dac47e9"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getPBKDF2
(
"passwordPASSWORDpassword"
.
getBytes
(),
"saltSALTsaltSALTsaltSALTsaltSALTsalt"
.
getBytes
(),
4096
,
40
)));
assertEquals
(
"89b69d0516f829893c696226650a8687"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getPBKDF2
(
"pass\0word"
.
getBytes
(),
"sa\0lt"
.
getBytes
(),
4096
,
16
)));
// the password is filled with zeroes
byte
[]
password
=
"Test"
.
getBytes
();
SHA256
.
getPBKDF2
(
password
,
""
.
getBytes
(),
1
,
16
);
assertEquals
(
new
byte
[
4
],
password
);
}
private
void
testHMAC
()
{
// from Wikipedia
assertEquals
(
"b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getHMAC
(
new
byte
[
0
],
new
byte
[
0
])));
assertEquals
(
"f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
,
StringUtils
.
convertBytesToHex
(
SHA256
.
getHMAC
(
"key"
.
getBytes
(),
"The quick brown fox jumps over the lazy dog"
.
getBytes
())));
}
private
String
getHashString
(
byte
[]
data
)
{
private
String
getHashString
(
byte
[]
data
)
{
byte
[]
result
=
SHA256
.
getHash
(
data
,
true
);
byte
[]
result
=
SHA256
.
getHash
(
data
,
true
);
if
(
data
.
length
>
0
)
{
if
(
data
.
length
>
0
)
{
...
...
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论