mirror of
https://github.com/gilbertchen/duplicacy
synced 2025-12-06 00:03:38 +00:00
Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b8922754 | ||
|
|
93cc632021 | ||
|
|
48cc5eaedb | ||
|
|
f304b64b3f | ||
|
|
8ae7d2a97d | ||
|
|
fce4234861 | ||
|
|
e499a24202 | ||
|
|
89769f3906 | ||
|
|
798cec0714 | ||
|
|
72dfaa8b6b | ||
|
|
117cfd997f | ||
|
|
84f7c513d5 | ||
|
|
dfdbfed64b | ||
|
|
d4a65ffbcf | ||
|
|
736003323a | ||
|
|
0af74616b7 | ||
|
|
0f552c8c50 | ||
|
|
1adf92e879 | ||
|
|
f92f1a728c | ||
|
|
9a0dcdb0b2 | ||
|
|
9ae306644d | ||
|
|
6f0166be6d | ||
|
|
f68eb13584 | ||
|
|
dd53b4797e | ||
|
|
7e021f26d3 | ||
|
|
0e585e4be4 | ||
|
|
e03cd2a880 | ||
|
|
f80a5b1025 | ||
|
|
aadd2aa390 | ||
|
|
72239a31c4 | ||
|
|
c9b60cc0e0 | ||
|
|
f4cdd1f01b | ||
|
|
b1c1b47983 | ||
|
|
8c3ef6cae9 | ||
|
|
acd7addc9a | ||
|
|
c23ea30da4 | ||
|
|
a4c46624ea | ||
|
|
5747d6763f | ||
|
|
ef1d316f33 | ||
|
|
714a45c34a | ||
|
|
23a2d91608 | ||
|
|
23b98a3034 | ||
|
|
8a3c5847a8 | ||
|
|
4c3d5dbc2f | ||
|
|
85bc55e374 | ||
|
|
cd0c7b07a9 | ||
|
|
0ea26a92dd | ||
|
|
ca889fca9f | ||
|
|
fbaea6e8b1 | ||
|
|
2290c4ace0 | ||
|
|
02cd41f4d0 | ||
|
|
0db8b9831b | ||
|
|
4dd5c43307 | ||
|
|
6aedc37118 | ||
|
|
9a56ede07c | ||
|
|
c6e9460b7b | ||
|
|
e74ab809ae | ||
|
|
5d2242d39d | ||
|
|
b99f4bffec | ||
|
|
be2856ebbd | ||
|
|
1ea615fb45 | ||
|
|
7d933a2576 | ||
|
|
13fffc2a11 | ||
|
|
9658463ebe | ||
|
|
cd77a029ea | ||
|
|
4948806d3d | ||
|
|
42c317c477 | ||
|
|
013eac0cf2 | ||
|
|
bc9ccd860f | ||
|
|
25935ca324 | ||
|
|
bcace5aee2 | ||
|
|
8fdb399e1b | ||
|
|
e07226bd62 | ||
|
|
9d632c0434 | ||
|
|
cc6e96527e | ||
|
|
ddf61aee9d | ||
|
|
52fd553bb9 | ||
|
|
7230ddbef5 | ||
|
|
ffe04d691b | ||
|
|
e0d7355494 | ||
|
|
d330f61d25 | ||
|
|
e5beb55336 | ||
|
|
57082cd1d2 | ||
|
|
bd5a689b7d | ||
|
|
b52d6b3f7f | ||
|
|
8aaca37a2b | ||
|
|
9898f77d9c | ||
|
|
30f753e499 | ||
|
|
d0771be2dd | ||
|
|
25fbc9ad03 | ||
|
|
91f02768f9 | ||
|
|
8e8a116028 | ||
|
|
771323510d | ||
|
|
61fb0f7b40 | ||
|
|
f1060491ae | ||
|
|
837fd5e4fd | ||
|
|
0670f709f3 | ||
|
|
f944e01a02 | ||
|
|
f6ef9094bc | ||
|
|
36d7c583fa | ||
|
|
9fdff7b150 | ||
|
|
dfbc5ece00 | ||
|
|
50d2e2603a | ||
|
|
61e4329522 | ||
|
|
801433340a | ||
|
|
91a95d0cd3 | ||
|
|
612f6e27cb | ||
|
|
430d7b6241 | ||
|
|
c5e2032715 | ||
|
|
048827742c | ||
|
|
0576efe36c | ||
|
|
8bd463288f | ||
|
|
2f4e7422ca | ||
|
|
9dbf517e8a | ||
|
|
e93ee2d776 | ||
|
|
3371ea445e | ||
|
|
6f69aff712 | ||
|
|
7a7ea3ad18 | ||
|
|
4aa2edb164 | ||
|
|
29bbd49a1c | ||
|
|
c829b80527 | ||
|
|
81e889ef3f | ||
|
|
1d5b910f5e | ||
|
|
ce946f7745 | ||
|
|
b9e89b2530 | ||
|
|
63aa47f193 | ||
|
|
214a119507 | ||
|
|
34e49d4589 | ||
|
|
8da36e9998 | ||
|
|
fe9cd7c8a8 | ||
|
|
90e1639611 | ||
|
|
1925b8d5fd | ||
|
|
65fca6f5c8 | ||
|
|
8fbef22429 | ||
|
|
1dcb3a05fc | ||
|
|
91d31f4091 | ||
|
|
579330b23c | ||
|
|
5caa15eeb8 | ||
|
|
652ebaca16 | ||
|
|
2bd9406244 | ||
|
|
9ac6e8713f | ||
|
|
dc9df61d37 | ||
|
|
73ed56e9cc | ||
|
|
69286a5413 | ||
|
|
5e6c2cc9c5 | ||
|
|
a6d071e1b5 | ||
|
|
8600803ba0 | ||
|
|
a6de3c1e74 | ||
|
|
669d5ed3f4 | ||
|
|
eb1c26b319 | ||
|
|
86767b3df6 | ||
|
|
5d905c83b8 | ||
|
|
57edf5823d | ||
|
|
7e1fb6130a | ||
|
|
8ad981b64d | ||
|
|
787c421a0c | ||
|
|
b0b08cec4c | ||
|
|
9608a7f6b6 | ||
|
|
bdea4bed15 | ||
|
|
0db7470af5 | ||
|
|
c08a26a0c2 | ||
|
|
b788b9887c | ||
|
|
4640c20dec | ||
|
|
47137b85e3 | ||
|
|
9d38b49e42 | ||
|
|
b0a67cefb7 | ||
|
|
6fd85fc687 | ||
|
|
b2ad6da364 | ||
|
|
a342431b3c | ||
|
|
ff27cec2af | ||
|
|
746c1656a8 | ||
|
|
2f6287a45d | ||
|
|
32d0f97bfb | ||
|
|
86a6ededab | ||
|
|
6e3c1657fa | ||
|
|
be89d8d0dc | ||
|
|
f044d37b28 | ||
|
|
04debec0a1 | ||
|
|
0784644996 | ||
|
|
f57fe55543 | ||
|
|
be2c3931cd | ||
|
|
a5d3340837 | ||
|
|
bd39302eee | ||
|
|
0dd138e16f | ||
|
|
7162d8916e | ||
|
|
f9603dad3c | ||
|
|
80742ce2ba | ||
|
|
ce52ec1e5d | ||
|
|
8841ced1f5 | ||
|
|
5031ae15d0 | ||
|
|
3dad87f13a | ||
|
|
6c96c52a93 | ||
|
|
2c2884abfb | ||
|
|
ed52850c98 | ||
|
|
46917ddf6b | ||
|
|
923cd0aa63 | ||
|
|
0fee771a74 | ||
|
|
b3d1eb36bd | ||
|
|
3c03b566ae | ||
|
|
978212fd75 | ||
|
|
bb1a15382e | ||
|
|
d20ea41cd0 | ||
|
|
ef19a3705f | ||
|
|
fc71cb1b49 | ||
|
|
6a03a98f55 | ||
|
|
45bc778898 | ||
|
|
d5d7649041 | ||
|
|
f1fe64b9cc | ||
|
|
e2fe57e959 | ||
|
|
ae44bf7226 | ||
|
|
fab9cc77c6 | ||
|
|
c63621cb8c | ||
|
|
f20e823119 | ||
|
|
805f6fd15d | ||
|
|
f25783d59d | ||
|
|
3cf3ad06fa | ||
|
|
d3cea2c7d0 | ||
|
|
f74ea0368e | ||
|
|
6bffef36bf | ||
|
|
b56d7dedba | ||
|
|
554f63263f | ||
|
|
bfb7370ff2 | ||
|
|
03c2a190ee | ||
|
|
491252e3e4 | ||
|
|
84fc1343a7 | ||
|
|
c42a5a86a4 | ||
|
|
d1817ae557 | ||
|
|
eb4c875fd0 | ||
|
|
cecb73071e | ||
|
|
0bf66168fb | ||
|
|
d8573ca789 | ||
|
|
6b2f50a1e8 | ||
|
|
81b8550232 | ||
|
|
f6e2877948 | ||
|
|
de2f7c447f | ||
|
|
3c1057a3c6 | ||
|
|
8808ad5c28 | ||
|
|
707967e91b | ||
|
|
3f83890859 | ||
|
|
68fb6d671e | ||
|
|
b04ef67d26 | ||
|
|
72ba2dfa87 | ||
|
|
b41e8a24a9 | ||
|
|
a3aa575c68 | ||
|
|
e765575210 | ||
|
|
044e1862e5 | ||
|
|
612c5b7746 | ||
|
|
457e518151 | ||
|
|
34afc6f93c | ||
|
|
030cd274c2 | ||
|
|
197d20f0e0 | ||
|
|
93cfbf27cb | ||
|
|
46ec852d4d | ||
|
|
dfa6113279 | ||
|
|
d7fdb5fe7f | ||
|
|
37ebbc4736 | ||
|
|
3ae2de241e | ||
|
|
4adb8dbf70 | ||
|
|
41e3d267e5 | ||
|
|
3e23b0c61c | ||
|
|
b7f537de3c | ||
|
|
0c8a88d15a | ||
|
|
204f56e939 | ||
|
|
4a80d94b63 | ||
|
|
3729de1c67 | ||
|
|
6f70b37d61 | ||
|
|
7baf8702a3 | ||
|
|
8fce6f5f83 | ||
|
|
fd362be54a | ||
|
|
0c13da9872 | ||
|
|
4912911017 | ||
|
|
f69550d0db | ||
|
|
799b040913 | ||
|
|
41e3843bfa | ||
|
|
9e1d2ac1e6 | ||
|
|
bc40498d1b | ||
|
|
446bb4bcc8 | ||
|
|
150ea13a0d | ||
|
|
8c5b7d5f63 | ||
|
|
315dfff7d6 | ||
|
|
0bc475ca4d | ||
|
|
a0fa0fe7da | ||
|
|
01db72080c | ||
|
|
22ddc04698 | ||
|
|
2aa3b2b737 | ||
|
|
76f75cb0cb | ||
|
|
ea4c4339e6 | ||
|
|
fa294eabf4 | ||
|
|
0ec262fd93 | ||
|
|
db3e0946bb | ||
|
|
c426bf5af2 | ||
|
|
823b82060c | ||
|
|
4308e3e6e9 | ||
|
|
0391ecf941 | ||
|
|
7ecf895d85 | ||
|
|
a43114da99 | ||
|
|
caaff6b4b2 | ||
|
|
18964e89a1 | ||
|
|
2d1ea86d8e | ||
|
|
d881ac9169 | ||
|
|
1aee9bd6ef | ||
|
|
f3447bb611 | ||
|
|
9be4927c87 | ||
|
|
a0fcb8802b | ||
|
|
58cfeec6ab | ||
|
|
0d442e736d | ||
|
|
b32bda162d | ||
|
|
e6767bfad4 | ||
|
|
0b9e23fcd8 | ||
|
|
7f04a79111 | ||
|
|
211c6867d3 | ||
|
|
4a31fcfb68 | ||
|
|
6a4b1f2a3f | ||
|
|
483ae5e6eb | ||
|
|
f8d879d414 | ||
|
|
c2120ad3d5 | ||
|
|
f8764a5a79 | ||
|
|
736b4da0c3 | ||
|
|
0aa122609a | ||
|
|
18462cf585 | ||
|
|
e06283f0b3 | ||
|
|
b4f3142275 | ||
|
|
cdd1f26079 | ||
|
|
199e312bea | ||
|
|
88141216e9 | ||
|
|
f9ede565ff | ||
|
|
93a61a6e49 | ||
|
|
7d31199631 | ||
|
|
f2451911f2 | ||
|
|
ac655c8780 | ||
|
|
c31d2a30d9 | ||
|
|
83da36cae0 | ||
|
|
96e2f78096 | ||
|
|
593b409329 | ||
|
|
5334f45998 | ||
|
|
b56baa80c3 | ||
|
|
74ab8d8c23 | ||
|
|
a7613ab7d9 | ||
|
|
65127c7ab7 | ||
|
|
09f695b3e1 | ||
|
|
2908b807b9 | ||
|
|
ba3702647b | ||
|
|
0a149cd509 | ||
|
|
2cbb72c2d0 | ||
|
|
12134ea6ad | ||
|
|
4291bc775b | ||
|
|
817e36c7a6 | ||
|
|
b7b54478fc | ||
|
|
8d06fa491a | ||
|
|
42a6ab9140 | ||
|
|
bad990e702 | ||
|
|
d27335ad8d | ||
|
|
a584828e1b | ||
|
|
d0c376f593 | ||
|
|
a54029cf2b | ||
|
|
6a73a62591 |
5
.github/ISSUE_TEMPLATE.md
vendored
Normal file
5
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Please submit an issue for bug reports or feature requests. If you have any questions please post them on https://forum.duplicacy.com.
|
||||
|
||||
When you're reporting a bug, please specify the OS, version, command line arguments, or any info that you think is helpful for the diagnosis. If Duplicacy reports an error, please post the program output here.
|
||||
|
||||
Note that this repository hosts the CLI version of Duplicacy only. If you're reporting anything related to the GUI version, please visit https://forum.duplicacy.com.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.idea
|
||||
duplicacy_main
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Duplicacy is based on the following open source project:
|
||||
Duplicacy is based on the following open source projects:
|
||||
|
||||
| Projects | License |
|
||||
|--------|:-------:|
|
||||
@@ -8,7 +8,9 @@ Duplicacy is based on the following open source project:
|
||||
|https://github.com/Azure/azure-sdk-for-go | Apache-2.0 |
|
||||
|https://github.com/tj/go-dropbox | MIT |
|
||||
|https://github.com/aws/aws-sdk-go | Apache-2.0 |
|
||||
|https://github.com/goamz/goamz | LGPL with static link exception |
|
||||
|https://github.com/howeyc/gopass | ISC |
|
||||
|https://github.com/tmc/keyring | ISC |
|
||||
|https://github.com/pcwizz/xattr | BSD-2-Clause |
|
||||
|https://github.com/minio/blake2b-simd | Apache-2.0 |
|
||||
|https://github.com/go-ole/go-ole | MIT |
|
||||
|
||||
218
DESIGN.md
218
DESIGN.md
@@ -1,215 +1,5 @@
|
||||
## Lock-Free Deduplication
|
||||
|
||||
The three elements of lock-free deduplication are:
|
||||
|
||||
* Use variable-size chunking algorithm to split files into chunks
|
||||
* Store each chunk in the storage using a file name derived from its hash, and rely on the file system API to manage chunks without using a centralized indexing database
|
||||
* Apply a *two-step fossil collection* algorithm to remove chunks that become unreferenced after a backup is deleted
|
||||
|
||||
The variable-size chunking algorithm, also called Content-Defined Chunking, is well-known and has been adopted by many
|
||||
backup tools. The main advantage of the variable-size chunking algorithm over the fixed-size chunking algorithm (as used
|
||||
by rsync) is that in the former the rolling hash is only used to search for boundaries between chunks, after which a far
|
||||
more collision-resistant hash function like MD5 or SHA256 is applied on each chunk. In contrast, in the fixed-size
|
||||
chunking algorithm, for the purpose of detecting inserts or deletions, a lookup in the known hash table is required every
|
||||
time the rolling hash window is shifted by one byte, thus significantly reducing the chunk splitting performance.
|
||||
|
||||
What is novel about lock-free deduplication is the absence of a centralized indexing database for tracking all existing
|
||||
chunks and for determining which chunks are not needed any more. Instead, to check if a chunk has already been uploaded
|
||||
before, one can just perform a file lookup via the file storage API using the file name derived from the hash of the chunk.
|
||||
This effectively turns a cloud storage offering only a very limited
|
||||
set of basic file operations into a powerful modern backup backend capable of both block-level and file-level deduplication. More importantly, the absence of a centralized indexing database means that there is no need to implement a distributed locking mechanism on top of the file storage.
|
||||
|
||||
By eliminating the chunk indexing database, lock-free duplication not only reduces the code complexity but also makes the deduplication less error-prone. Each chunk is saved individually in its own file, and once saved there is no need for modification. Data corruption is therefore less likely to occur because of the immutability of chunk files. Another benefit that comes naturally from lock-free duplication is that when one client creates a new chunk, other clients that happen to have the same original file will notice that the chunk already exist and therefore will not upload the same chunk again. This pushes the deduplication to its highest level -- clients without knowledge of each other can share identical chunks with no extra effort.
|
||||
|
||||
There is one problem, though.
|
||||
Deletion of snapshots without an indexing database, when concurrent access is permitted, turns out to be a hard problem.
|
||||
If exclusive access to a file storage by a single client can be guaranteed, the deletion procedure can simply search for
|
||||
chunks not referenced by any backup and delete them. However, if concurrent access is required, an unreferenced chunk
|
||||
can't be trivially removed, because of the possibility that a backup procedure in progress may reference the same chunk.
|
||||
The ongoing backup procedure, still unknown to the deletion procedure, may have already encountered that chunk during its
|
||||
file scanning phase, but decided not to upload the chunk again since it already exists in the file storage.
|
||||
|
||||
Fortunately, there is a solution to address the deletion problem and make lock-free deduplication practical. The solution is a *two-step fossil collection* algorithm that deletes unreferenced chunks in two steps: identify and collect them in the first step, and then permanently remove them once certain conditions are met.
|
||||
|
||||
## Two-Step Fossil Collection
|
||||
|
||||
Interestingly, the two-step fossil collection algorithm hinges on a basic file operation supported almost universally, *file renaming*.
|
||||
When the deletion procedure identifies a chunk not referenced by any known snapshots, instead of deleting the chunk file
|
||||
immediately, it changes the name of the chunk file (and possibly moves it to a different directory).
|
||||
A chunk that has been renamed is called a *fossil*.
|
||||
|
||||
The fossil still exists in the file storage. Two rules are enforced regarding the access of fossils:
|
||||
|
||||
* A restore, list, or check procedure that reads existing backups can read the fossil if the original chunk cannot be found.
|
||||
* A backup procedure does not check the existence of a fossil. That is, it must upload a chunk if it cannot find the chunk, even if an equivalent fossil exists.
|
||||
|
||||
In the first step of the deletion procedure, called the *fossil collection* step, the names of all identified fossils will
|
||||
be saved in a fossil collection file. The deletion procedure then exits without performing further actions. This step has not effectively changed any chunk references due to the first fossil access rule. If a backup procedure references a chunk after it is marked as a fossil, a new chunk will be uploaded because of the second fossil access rule, as shown in Figure 1.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/gilbertchen/duplicacy-beta/blob/master/images/fossil_collection_1.png?raw=true"
|
||||
alt="Reference after Rename"/>
|
||||
</p>
|
||||
|
||||
The second step, called the *fossil deletion* step, will permanently delete fossils, but only when two conditions are met:
|
||||
|
||||
* For each snapshot id, there is a new snapshot that was not seen by the fossil collection step
|
||||
* The new snapshot must finish after the fossil collection step
|
||||
|
||||
The first condition guarantees that if a backup procedure references a chunk before the deletion procedure turns it into a fossil, the reference will be detected in the fossil deletion step which will then turn the fossil back into a normal chunk.
|
||||
|
||||
The second condition guarantees that any backup procedure unknown to the fossil deletion step can start only after the fossil collection step finishes. Therefore, if it references a chunk that was identified as fossil in the fossil collection step, it should observe the fossil, not the chunk, so it will upload a new chunk, according to the second fossil access rule.
|
||||
|
||||
Therefore, if a backup procedure references a chunk before the chunk is marked a fossil, the fossil deletion step will not
|
||||
delete the chunk until it sees that backup procedure finishes (as indicated by the appearance of a new snapshot file uploaded to the storage). This ensures that scenarios depicted in Figure 2 will never happen.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/gilbertchen/duplicacy-beta/blob/master/images/fossil_collection_2.png?raw=true"
|
||||
alt="Reference before Rename"/>
|
||||
</p>
|
||||
|
||||
|
||||
## Snapshot Format
|
||||
|
||||
A snapshot file is a file that the backup procedure uploads to the file storage after it finishes splitting files into
|
||||
chunks and uploading all new chunks. It mainly contains metadata for the backup overall, metadata for all the files,
|
||||
and chunk references for each file. Here is an example snapshot file for a repository containing 3 files (file1, file2,
|
||||
and dir1/file3):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "host1",
|
||||
"revision": 1,
|
||||
"tag": "first",
|
||||
"start_time": 1455590487,
|
||||
"end_time": 1455590487,
|
||||
"files": [
|
||||
{
|
||||
"path": "file1",
|
||||
"content": "0:0:2:6108",
|
||||
"hash": "a533c0398194f93b90bd945381ea4f2adb0ad50bd99fd3585b9ec809da395b51",
|
||||
"size": 151901,
|
||||
"time": 1455590487,
|
||||
"mode": 420
|
||||
},
|
||||
{
|
||||
"path": "file2",
|
||||
"content": "2:6108:3:7586",
|
||||
"hash": "f6111c1562fde4df9c0bafe2cf665778c6e25b49bcab5fec63675571293ed644",
|
||||
"size": 172071,
|
||||
"time": 1455590487,
|
||||
"mode": 420
|
||||
},
|
||||
{
|
||||
"path": "dir1/",
|
||||
"size": 102,
|
||||
"time": 1455590487,
|
||||
"mode": 2147484096
|
||||
},
|
||||
{
|
||||
"path": "dir1/file3",
|
||||
"content": "3:7586:4:1734",
|
||||
"hash": "6bf9150424169006388146908d83d07de413de05d1809884c38011b2a74d9d3f",
|
||||
"size": 118457,
|
||||
"time": 1455590487,
|
||||
"mode": 420
|
||||
}
|
||||
],
|
||||
"chunks": [
|
||||
"9f25db00881a10a8e7bcaa5a12b2659c2358a579118ea45a73c2582681f12919",
|
||||
"6e903aace6cd05e26212fcec1939bb951611c4179c926351f3b20365ef2c212f",
|
||||
"4b0d017bce5491dbb0558c518734429ec19b8a0d7c616f68ddf1b477916621f7",
|
||||
"41841c98800d3b9faa01b1007d1afaf702000da182df89793c327f88a9aba698",
|
||||
"7c11ee13ea32e9bb21a694c5418658b39e8894bbfecd9344927020a9e3129718"
|
||||
],
|
||||
"lengths": [
|
||||
64638,
|
||||
81155,
|
||||
170593,
|
||||
124309,
|
||||
1734
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When Duplicacy splits a file in chunks using the variable-size chunking algorithm, if the end of a file is reached and yet the boundary marker for terminating a chunk
|
||||
hasn't been found, the next file, if there is one, will be read in and the chunking algorithm continues. It is as if all
|
||||
files were packed into a big tar file which is then split into chunks.
|
||||
|
||||
The *content* field of a file indicates the indexes of starting and ending chunks and the corresponding offsets. For
|
||||
instance, *file1* starts at chunk 0 offset 0 while ends at chunk 2 offset 6108, immediately followed by *file2*.
|
||||
|
||||
The backup procedure can run in one of two modes. In the default quick mode, only modified or new files are scanned. Chunks only
|
||||
referenced by old files that have been modified are removed from the chunk sequence, and then chunks referenced by new
|
||||
files are appended. Indices for unchanged files need to be updated too.
|
||||
|
||||
In the safe mode (enabled by the -hash option), all files are scanned and the chunk sequence is regenerated.
|
||||
|
||||
The length sequence stores the lengths for all chunks, which are needed when calculating some statistics such as the total
|
||||
length of chunks. For a repository containing a large number of files, the size of the snapshot file can be tremendous.
|
||||
To make the situation worse, every time a big snapshot file would have been uploaded even if only a few files have been changed since
|
||||
last backup. To save space, the variable-size chunking algorithm is also applied to the three dynamic fields of a snapshot
|
||||
file, *files*, *chunks*, and *lengths*.
|
||||
|
||||
Chunks produced during this step are deduplicated and uploaded in the same way as regular file chunks. The final snapshot file
|
||||
contains sequences of chunk hashes and other fixed size fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "host1",
|
||||
"revision": 1,
|
||||
"start_time": 1455590487,
|
||||
"tag": "first",
|
||||
"end_time": 1455590487,
|
||||
"file_sequence": [
|
||||
"21e4c69f3832e32349f653f31f13cefc7c52d52f5f3417ae21f2ef5a479c3437",
|
||||
],
|
||||
"chunk_sequence": [
|
||||
"8a36ffb8f4959394fd39bba4f4a464545ff3dd6eed642ad4ccaa522253f2d5d6"
|
||||
],
|
||||
"length_sequence": [
|
||||
"fc2758ae60a441c244dae05f035136e6dd33d3f3a0c5eb4b9025a9bed1d0c328"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In the extreme case where the repository has not been modified since last backup, a new backup procedure will not create any new chunks,
|
||||
as shown by the following output from a real use case:
|
||||
|
||||
```
|
||||
$ duplicacy backup -stats
|
||||
Storage set to sftp://gchen@192.168.1.100/Duplicacy
|
||||
Last backup at revision 260 found
|
||||
Backup for /Users/gchen/duplicacy at revision 261 completed
|
||||
Files: 42367 total, 2,204M bytes; 0 new, 0 bytes
|
||||
File chunks: 447 total, 2,238M bytes; 0 new, 0 bytes, 0 bytes uploaded
|
||||
Metadata chunks: 6 total, 11,753K bytes; 0 new, 0 bytes, 0 bytes uploaded
|
||||
All chunks: 453 total, 2,249M bytes; 0 new, 0 bytes, 0 bytes uploaded
|
||||
Total running time: 00:00:05
|
||||
```
|
||||
|
||||
## Encryption
|
||||
|
||||
When encryption is enabled (by the -e option with the *init* or *add* command), Duplicacy will generate 4 random 256 bit keys:
|
||||
|
||||
* *Hash Key*: for generating a chunk hash from the content of a chunk
|
||||
* *ID Key*: for generating a chunk id from a chunk hash
|
||||
* *Chunk Key*: for encrypting chunk files
|
||||
* *File Key*: for encrypting non-chunk files such as snapshot files.
|
||||
|
||||
Here is a diagram showing how these keys are used:
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/gilbertchen/duplicacy-beta/blob/master/images/duplicacy_encryption.png?raw=true"
|
||||
alt="encryption"/>
|
||||
</p>
|
||||
|
||||
Chunk hashes are used internally and stored in the snapshot file. They are never exposed unless the snapshot file is decrypted. Chunk ids are used as the file names for the chunks and therefore exposed. When the *cat* command is used to print out a snapshot file, the chunk hashes stored in the snapshot file will be converted into chunk ids first which are then displayed instead.
|
||||
|
||||
Chunk content is encrypted by AES-GCM, with an encryption key that is the HMAC-SHA256 of the chunk Hash with the *Chunk Key* as the secret key.
|
||||
|
||||
The snapshot is encrypted by AES-GCM too, using an encrypt key that is the HMAC-SHA256 of the file path with the *File Key* as the secret key.
|
||||
|
||||
These four random keys are saved in a file named 'config' in the storage, encrypted with a master key derived from the PBKDF2 function on
|
||||
the storage password chosen by the user.
|
||||
All documentation has been moved to our wiki page:
|
||||
|
||||
* [Lock-Free Deduplication](https://github.com/gilbertchen/duplicacy/wiki/Lock-Free-Deduplication)
|
||||
* [Snapshot Format](https://github.com/gilbertchen/duplicacy/wiki/Snapshot-Format)
|
||||
* [Encryption](https://github.com/gilbertchen/duplicacy/wiki/Encryption)
|
||||
|
||||
525
GUIDE.md
525
GUIDE.md
@@ -1,505 +1,20 @@
|
||||
# Duplicacy User Guide
|
||||
|
||||
## Commands
|
||||
|
||||
#### Init
|
||||
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy init - Initialize the storage if necessary and the current directory as the repository
|
||||
|
||||
USAGE:
|
||||
duplicacy init [command options] <snapshot id> <storage url>
|
||||
|
||||
OPTIONS:
|
||||
-encrypt, -e encrypt the storage with a password
|
||||
-chunk-size, -c 4M the average size of chunks
|
||||
-max-chunk-size, -max 16M the maximum size of chunks (defaults to chunk-size * 4)
|
||||
-min-chunk-size, -min 1M the minimum size of chunks (defaults to chunk-size / 4)
|
||||
-pref-dir <preference directory path> Specify alternate location for .duplicacy preferences directory
|
||||
```
|
||||
|
||||
The *init* command first connects to the storage specified by the storage URL. If the storage has been already been
|
||||
initialized before, it will download the storage configuration (stored in the file named *config*) and ignore the options provided in the command line. Otherwise, it will create the configuration file from the options and upload the file.
|
||||
|
||||
The initialized storage will then become the default storage for other commands if the -storage option is not specified
|
||||
for those commands. This default storage actually has a name, *default*.
|
||||
|
||||
After that, it will prepare the the current working directory as the repository to be backed up. Under the hood, it will create a directory
|
||||
named *.duplicacy* in the repository and put a file named *preferences* that stores the snapshot id and encryption and storage options.
|
||||
|
||||
The snapshot id is an id used to distinguish different repositories connected to the same storage. Each repository must have a unique snapshot id.
|
||||
|
||||
The -e option controls whether or not encryption will be enabled for the storage. If encryption is enabled, you will be prompted to enter a storage password.
|
||||
|
||||
The three chunk size parameters are passed to the variable-size chunking algorithm. Their values are important to the overall performance, especially for cloud storages. If the chunk size is too small, a lot of overhead will be in sending requests and receiving responses. If the chunk size is too large, the effect of deduplication will be less obvious as more data will need to be transferred with each chunk.
|
||||
|
||||
The -pref-dir controls the location of the preferences directory. If not specified, a directory named .duplicacy is created in the repository. If specified, it must point to a non-existing directory. The directory is created and a .duplicacy file is created in the repository. The .duplicacy file contains the absolute path name to the preferences directory.
|
||||
|
||||
Once a storage has been initialized with these parameters, these parameters cannot be modified any more.
|
||||
|
||||
#### Backup
|
||||
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy backup - Save a snapshot of the repository to the storage
|
||||
|
||||
USAGE:
|
||||
duplicacy backup [command options]
|
||||
|
||||
OPTIONS:
|
||||
-hash detect file differences by hash (rather than size and timestamp)
|
||||
-t <tag> assign a tag to the backup
|
||||
-stats show statistics during and after backup
|
||||
-threads <n> number of uploading threads
|
||||
-limit-rate <kB/s> the maximum upload rate (in kilobytes/sec)
|
||||
-vss enable the Volume Shadow Copy service (Windows only)
|
||||
-storage <storage name> backup to the specified storage instead of the default one
|
||||
```
|
||||
|
||||
The *backup* command creates a snapshot of the repository and uploads it to the storage. If -hash is not provided,
|
||||
it will upload new or modified files since last backup by comparing file sizes and timestamps.
|
||||
Otherwise, every file is scanned to detect changes.
|
||||
|
||||
You can assign a tag to the snapshot so that later you can refer to it by tag in other commands.
|
||||
|
||||
If the -stats option is specified, statistical information such as transfer speed, the number of chunks will be displayed
|
||||
throughout the backup procedure.
|
||||
|
||||
The -threads option can be used to specify more than one thread to upload chunks.
|
||||
|
||||
The -limit-rate option sets a cape on the maximum upload rate.
|
||||
|
||||
The -vss option works on Windows only to turn on the Volume Shadow Copy service such that files opened by other
|
||||
processes with exclusive locks can be read as usual.
|
||||
|
||||
When the repository can have multiple storages (added by the *add* command), you can select the storage to back up to
|
||||
by giving a storage name.
|
||||
|
||||
You can specify patterns to include/exclude files by putting them in a file named *.duplicacy/filters*. Please refer to the [Include/Exclude Patterns](https://github.com/gilbertchen/duplicacy-beta/blob/master/GUIDE.md#includeexclude-patterns) section for how to specify the patterns.
|
||||
|
||||
#### Restore
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy restore - Restore the repository to a previously saved snapshot
|
||||
|
||||
USAGE:
|
||||
duplicacy restore [command options] [--] [pattern] ...
|
||||
|
||||
OPTIONS:
|
||||
-r <revision> the revision number of the snapshot (required)
|
||||
-hash detect file differences by hash (rather than size and timestamp)
|
||||
-overwrite overwrite existing files in the repository
|
||||
-delete delete files not in the snapshot
|
||||
-stats show statistics during and after restore
|
||||
-threads <n> number of downloading threads
|
||||
-limit-rate <kB/s> the maximum download rate (in kilobytes/sec)
|
||||
-storage <storage name> restore from the specified storage instead of the default one
|
||||
```
|
||||
|
||||
The *restore* command restores the repository to a previous revision. By default the restore procedure will treat
|
||||
files that have the same sizes and timestamps as those in the snapshot as unchanged files, but with the -hash option, every file will be fully scanned to make sure they are in fact unchanged.
|
||||
|
||||
By default the restore procedure will not overwriting existing files, unless the -overwrite option is specified.
|
||||
|
||||
The -delete option indicates that files not in the snapshot will be removed.
|
||||
|
||||
If the -stats option is specified, statistical information such as transfer speed, number of chunks will be displayed
|
||||
throughout the restore procedure.
|
||||
|
||||
The -threads option can be used to specify more than one thread to download chunks.
|
||||
|
||||
The -limit-rate option sets a cape on the maximum upload rate.
|
||||
|
||||
When the repository can have multiple storages (added by the *add* command), you can select the storage to restore from by specifying the storage name.
|
||||
|
||||
Unlike the *backup* procedure that reading the include/exclude patterns from a file, the *restore* procedure reads them
|
||||
from the command line. If the patterns can cause confusion to the command line argument parser, -- should be prepended to
|
||||
the patterns. Please refer to the [Include/Exclude Patterns](https://github.com/gilbertchen/duplicacy-beta/blob/master/GUIDE.md#includeexclude-patterns) section for how to specify patterns.
|
||||
|
||||
|
||||
#### List
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy list - List snapshots
|
||||
|
||||
USAGE:
|
||||
duplicacy list [command options]
|
||||
|
||||
OPTIONS:
|
||||
-all, -a list snapshots with any id
|
||||
-id <snapshot id> list snapshots with the specified id rather than the default one
|
||||
-r <revision> [+] the revision number of the snapshot
|
||||
-t <tag> list snapshots with the specified tag
|
||||
-files print the file list in each snapshot
|
||||
-chunks print chunks in each snapshot or all chunks if no snapshot specified
|
||||
-reset-password take passwords from input rather than keychain/keyring or env
|
||||
-storage <storage name> retrieve snapshots from the specified storage
|
||||
```
|
||||
|
||||
The *list* command lists information about specified snapshots. By default it will list snapshots created from the
|
||||
current repository, but you can list all snapshots stored in the storage by specifying the -all option, or list snapshots
|
||||
with a different snapshot id using the -id option, and/or snapshots with a particular tag with the -t option.
|
||||
|
||||
The revision number is a number assigned to the snapshot when it is being created. This number will keep increasing
|
||||
every time a new snapshot is created from a repository. You can refer to snapshots by their revision numbers using
|
||||
the -r option, which either takes a single revision number (-r 123) or a range (-r 123-456).
|
||||
There can be multiple -r options.
|
||||
|
||||
If -files is specified, for each snapshot to be listed, this command will also print information about every file
|
||||
contained in the snapshot.
|
||||
|
||||
If -chunks is specified, the command will also print out every chunk the snapshot references.
|
||||
|
||||
The -reset-password option is used to reset stored passwords and to allow passwords to be entered again. Please refer to the [Managing Passwords](https://github.com/gilbertchen/duplicacy-beta/blob/master/GUIDE.md#managing-passwords) section for more information.
|
||||
|
||||
When the repository can have multiple storages (added by the *add* command), you can specify the storage to list
|
||||
by specifying the storage name.
|
||||
|
||||
#### Check
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy check - Check the integrity of snapshots
|
||||
|
||||
USAGE:
|
||||
duplicacy check [command options]
|
||||
|
||||
OPTIONS:
|
||||
-all, -a check snapshots with any id
|
||||
-id <snapshot id> check snapshots with the specified id rather than the default one
|
||||
-r <revision> [+] the revision number of the snapshot
|
||||
-t <tag> check snapshots with the specified tag
|
||||
-fossils search fossils if a chunk can't be found
|
||||
-resurrect turn referenced fossils back into chunks
|
||||
-files verify the integrity of every file
|
||||
-stats show deduplication statistics (imply -all and all revisions)
|
||||
-storage <storage name> retrieve snapshots from the specified storage```
|
||||
```
|
||||
The *check* command checks, for each specified snapshot, that all referenced chunks exist in the storage.
|
||||
|
||||
By default the *check* command will check snapshots created from the
|
||||
current repository, but you can check all snapshots stored in the storage at once by specifying the -all option, or
|
||||
snapshots from a different repository using the -id option, and/or snapshots with a particular tag with the -t option.
|
||||
|
||||
The revision number is a number assigned to the snapshot when it is being created. This number will keep increasing
|
||||
every time a new snapshot is created from a repository. You can refer to snapshots by their revision numbers using
|
||||
the -r option, which either takes a single revision number (-r 123) or a range (-r 123-456).
|
||||
There can be multiple -r options.
|
||||
|
||||
By default the *check* command only verifies the existence of chunks. To verify the full integrity of a snapshot,
|
||||
you should specify the -files option, which will download chunks and compute file hashes in memory, to
|
||||
make sure that all hashes match.
|
||||
|
||||
By default the *check* command does not find fossils. If the -fossils option is specified, it will find
|
||||
the fossil if the referenced chunk does not exist. if the -resurrect option is specified, it will turn the fossil back into a chunk.
|
||||
|
||||
When the repository can have multiple storages (added by the *add* command), you can specify the storage to check
|
||||
by specifying the storage name.
|
||||
|
||||
|
||||
#### Cat
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy cat - Print to stdout the specified file, or the snapshot content if no file is specified
|
||||
|
||||
USAGE:
|
||||
duplicacy cat [command options] [<file>]
|
||||
|
||||
OPTIONS:
|
||||
-id <snapshot id> retrieve from the snapshot with the specified id
|
||||
-r <revision> the revision number of the snapshot
|
||||
-storage <storage name> retrieve the file from the specified storage
|
||||
```
|
||||
|
||||
The *cat* command prints a file or the entire snapshot content if no file is specified.
|
||||
|
||||
The file must be specified with a path relative to the repository.
|
||||
|
||||
You can specify a different snapshot id rather than the default id.
|
||||
|
||||
The -r option is optional. If not specified, the latest revision will be selected.
|
||||
|
||||
You can use the -storage option to select a different storage other than the default one.
|
||||
|
||||
#### Diff
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy diff - Compare two snapshots or two revisions of a file
|
||||
|
||||
USAGE:
|
||||
duplicacy diff [command options] [<file>]
|
||||
|
||||
OPTIONS:
|
||||
-id <snapshot id> diff with the snapshot with the specified id
|
||||
-r <revision> [+] the revision number of the snapshot
|
||||
-hash compute the hashes of on-disk files
|
||||
-storage <storage name> retrieve files from the specified storage
|
||||
```
|
||||
The *diff* command compares the same file in two different snapshots if a file is given, otherwise compares the
|
||||
two snapshots.
|
||||
|
||||
The file must be specified with a path relative to the repository.
|
||||
|
||||
You can specify a different snapshot id rather than the default snapshot id.
|
||||
|
||||
If only one revision is given by -r, the right hand side of the comparison will be the on-disk file.
|
||||
The -hash option can then instruct this command to compute the hash of the file.
|
||||
|
||||
You can use the -storage option to select a different storage other than the default one.
|
||||
|
||||
#### History
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy history - Show the history of a file
|
||||
|
||||
USAGE:
|
||||
duplicacy history [command options] <file>
|
||||
|
||||
OPTIONS:
|
||||
-id <snapshot id> find the file in the snapshot with the specified id
|
||||
-r <revision> [+] show history of the specified revisions
|
||||
-hash show the hash of the on-disk file
|
||||
-storage <storage name> retrieve files from the specified storage
|
||||
```
|
||||
|
||||
The *history* command shows how the hash, size, and timestamp of a file change over the specified set of revisions.
|
||||
|
||||
You can specify a different snapshot id rather than the default snapshot id, and multiple -r options to specify the
|
||||
set of revisions.
|
||||
|
||||
The -hash option is to compute the hash of the on-disk file. Otherwise, only the size and timestamp of the on-disk
|
||||
file will be included.
|
||||
|
||||
You can use the -storage option to select a different storage other than the default one.
|
||||
|
||||
#### Prune
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy prune - Prune snapshots by revision, tag, or retention policy
|
||||
|
||||
USAGE:
|
||||
duplicacy prune [command options]
|
||||
|
||||
OPTIONS:
|
||||
-id <snapshot id> delete snapshots with the specified id instead of the default one
|
||||
-all, -a match against all snapshot IDs
|
||||
-r <revision> [+] delete snapshots with the specified revisions
|
||||
-t <tag> [+] delete snapshots with the specified tags
|
||||
-keep <n:m> [+] keep 1 snapshot every n days for snapshots older than m days
|
||||
-exhaustive find all unreferenced chunks by scanning the storage
|
||||
-exclusive assume exclusive access to the storage (disable two-step fossil collection)
|
||||
-dry-run, -d show what would have been deleted
|
||||
-delete-only delete fossils previously collected (if deletable) and don't collect fossils
|
||||
-collect-only identify and collect fossils, but don't delete fossils previously collected
|
||||
-ignore <id> [+] ignore the specified snapshot id when deciding if fossils can be deleted
|
||||
-storage <storage name> prune snapshots from the specified storage
|
||||
```
|
||||
|
||||
The *prune* command implements the two-step fossil collection algorithm. It will first find fossil collection files
|
||||
from previous runs and check if contained fossils are eligible for permanent deletion (the fossil deletion step). Then it
|
||||
will search for snapshots to be deleted, mark unreferenced chunks as fossils (by renaming) and save them in a new fossil
|
||||
collection file stored locally (the fossil collection step).
|
||||
|
||||
If a snapshot id is specified, that snapshot id will be used instead of the default one. The -a option will find
|
||||
snapshots with any id. Snapshots to be deleted can be specified by revision numbers, by a tag, by retention policies,
|
||||
or by any combination of them.
|
||||
|
||||
The retention policies are specified by the -keep option, which accepts an argument in the form of two numbers *n:m*, where *n* indicates the number of days between two consecutive snapshots to keep, and *m* means that the policy only applies to snapshots at least *m* day old. If *n* is zero, any snapshots older than *m* days will be removed.
|
||||
|
||||
Here are a few sample retention policies:
|
||||
|
||||
```sh
|
||||
$ duplicacy prune -keep 1:7 # Keep 1 snapshot per day for snapshots older than 7 days
|
||||
$ duplicacy prune -keep 7:30 # Keep 1 snapshot every 7 days for snapshots older than 30 days
|
||||
$ duplicacy prune -keep 30:180 # Keep 1 snapshot every 30 days for snapshots older than 180 days
|
||||
$ duplicacy prune -keep 0:360 # Keep no snapshots older than 360 days
|
||||
```
|
||||
|
||||
Multiple -keep options must be sorted by their *m* values in decreasing order. For instance, to combine the above policies into one line, it would become:
|
||||
|
||||
```sh
|
||||
$ duplicacy prune -keep 0:360 -keep 30:180 -keep 7:30 -keep 1:7
|
||||
```
|
||||
|
||||
The -exhaustive option will scan the list of all chunks in the storage, therefore it will find not only
|
||||
unreferenced chunks from deleted snapshots, but also chunks that become unreferenced for other reasons, such as
|
||||
those from an incomplete backup. It will also find any file that does not look like a chunk file.
|
||||
In contrast, a default *prune* command will only identify
|
||||
chunks referenced by deleted snapshots but not any other snapshots.
|
||||
|
||||
The -exclusive option will assume that no other clients are accessing the storage, effectively disabling the
|
||||
*two-step fossil collection* algorithm. With this option, the *prune* command will immediately remove unreferenced chunks.
|
||||
|
||||
The -dryrun option is used to test what changes the *prune* command would have done. It is guaranteed not to make
|
||||
any changes on the storage, not even creating the local fossil collection file. The following command checks if the
|
||||
chunk directory is clean (i.e., if there are any unreferenced chunks, temporary files, or anything else):
|
||||
|
||||
```
|
||||
$ duplicacy prune -d -exclusive -exhaustive # Prints out nothing if the chunk directory is clean
|
||||
```
|
||||
|
||||
The -delete-only option will skip the fossil collection step, while the -collect-only option will skip the fossil deletion step.
|
||||
|
||||
For fossils collected in the fossil collection step to be eligible for safe deletion in the fossil deletion step, at least
|
||||
one new snapshot from *each* snapshot id must be created between two runs of the *prune* command. However, some repository
|
||||
may not be set up to back up with a regular schedule, and thus literally blocking other repositories from deleting any fossils. Duplicacy by default will ignore repositories that have no new backup in the past 7 days. It also provide an
|
||||
-ignore option that can be used to skip certain repositories when deciding the deletion criteria.
|
||||
|
||||
You can use the -storage option to select a different storage other than the default one.
|
||||
|
||||
|
||||
#### Password
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy password - Change the storage password
|
||||
|
||||
USAGE:
|
||||
duplicacy password [command options]
|
||||
|
||||
OPTIONS:
|
||||
-storage <storage name> change the password used to access the specified storage
|
||||
```
|
||||
|
||||
The *password* command decrypts the storage configuration file *config* using the old password, and re-encrypts the file
|
||||
using a new password. It does not change all the encryption keys used to encrypt and decrypt chunk files,
|
||||
snapshot files, etc.
|
||||
|
||||
You can specify the storage to change the password for when working with multiple storages.
|
||||
|
||||
|
||||
#### Add
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy add - Add an additional storage to be used for the existing repository
|
||||
|
||||
USAGE:
|
||||
duplicacy add [command options] <storage name> <snapshot id> <storage url>
|
||||
|
||||
OPTIONS:
|
||||
-encrypt, -e Encrypt the storage with a password
|
||||
-chunk-size, -c 4M the average size of chunks
|
||||
-max-chunk-size, -max 16M the maximum size of chunks (defaults to chunk-size * 4)
|
||||
-min-chunk-size, -min 1M the minimum size of chunks (defaults to chunk-size / 4)
|
||||
-compression-level, -l <level> compression level (defaults to -1)
|
||||
-copy <storage name> make the new storage copy-compatible with an existing one
|
||||
```
|
||||
|
||||
The *add* command connects another storage to the current repository. Like the *init* command, if the storage has not
|
||||
been initialized before, a storage configuration file derived from the command line options will be uploaded, but those
|
||||
options will be ignored if the configuration file already exists in the storage.
|
||||
|
||||
A unique storage name must be given in order to distinguish it from other storages.
|
||||
|
||||
The -copy option is required if later you want to copy snapshots between this storage and another storage.
|
||||
Two storages are copy-compatible if they have the same average chunk size, the same maximum chunk size,
|
||||
the same minimum chunk size, the same chunk seed (used in calculating the rolling hash in the variable-size chunks
|
||||
algorithm), and the same hash key. If the -copy option is specified, these parameters will be copied from
|
||||
the existing storage rather than from the command line.
|
||||
|
||||
#### Set
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy set - Change the options for the default or specified storage
|
||||
|
||||
USAGE:
|
||||
duplicacy set [command options]
|
||||
|
||||
OPTIONS:
|
||||
-encrypt, e[=true] encrypt the storage with a password
|
||||
-no-backup[=true] backup to this storage is prohibited
|
||||
-no-restore[=true] restore from this storage is prohibited
|
||||
-no-save-password[=true] don't save password or access keys to keychain/keyring
|
||||
-key add a key/password whose value is supplied by the -value option
|
||||
-value the value of the key/password
|
||||
-storage <storage name> use the specified storage instead of the default one
|
||||
```
|
||||
|
||||
The *set* command changes the options for the specified storage.
|
||||
|
||||
The -e option turns on the storage encryption. If specified as -e=false, it turns off the storage encryption.
|
||||
|
||||
The -no-backup option will not allow backups from this repository to be created.
|
||||
|
||||
The -no-restore option will not allow restoring this repository to a different revision.
|
||||
|
||||
The -no-save-password option will require every password or token to be entered every time and not saved anywhere.
|
||||
|
||||
The -key and -value options are used to store (in plain text) access keys or tokens need by various storages. Please
|
||||
refer to the [Managing Passwords](https://github.com/gilbertchen/duplicacy-beta/blob/master/GUIDE.md#managing-passwords) section for more details.
|
||||
|
||||
You can select a storage to change options for by specifying a storage name.
|
||||
|
||||
|
||||
#### Copy
|
||||
```
|
||||
SYNOPSIS:
|
||||
duplicacy copy - Copy snapshots between compatible storages
|
||||
|
||||
USAGE:
|
||||
duplicacy copy [command options]
|
||||
|
||||
OPTIONS:
|
||||
-id <snapshot id> copy snapshots with the specified id instead of all snapshot ids
|
||||
-r <revision> [+] copy snapshots with the specified revisions
|
||||
-from <storage name> copy snapshots from the specified storage
|
||||
-to <storage name> copy snapshots to the specified storage
|
||||
```
|
||||
|
||||
The *copy* command copies snapshots from one storage to another storage. They must be copy-compatible, i.e., some
|
||||
configuration parameters must be the same. One storage must be initialized with the -copy option provided by the *add* command.
|
||||
|
||||
Instead of copying all snapshots, you can specify a set of snapshots to copy by giving the -r options. The *copy* command
|
||||
preserves the revision numbers, so if a revision number already exists on the destination storage the command will fail.
|
||||
|
||||
If no -from option is given, the snapshots from the default storage will be copied. The -to option specified the
|
||||
destination storage and is required.
|
||||
|
||||
## Include/Exclude Patterns
|
||||
|
||||
An include pattern starts with +, and an exclude pattern starts with -. Patterns may contain wildcard characters such as * and ? with their normal meaning.
|
||||
|
||||
When matching a path against a list of patterns, the path is compared with the part after + or -, one pattern at a time. Therefore, the order of the patterns is significant. If a match with an include pattern is found, the path is said to be included without further comparisons. If a match with an exclude pattern is found, the path is said to be excluded without further comparison. If a match is not found, the path will be excluded if all patterns are include patterns, but included otherwise.
|
||||
|
||||
Patterns ending with a / apply to directories only, and patterns not ending with a / apply to files only. When a directory is excluded, all files and subdirectires under it will also be excluded. Note that the path separator is always /, even on Windows.
|
||||
|
||||
The following pattern list includes only files under the directory foo/ but not files under the subdirectory foo/bar:
|
||||
|
||||
```
|
||||
-foo/bar/
|
||||
+foo/*
|
||||
-*
|
||||
```
|
||||
|
||||
For the *backup* command, the include/exclude patterns are read from a file named *filters* under the *.duplicacy* directory.
|
||||
|
||||
For the *restore* command, the include/exclude patterns are specified as the command line arguments.
|
||||
|
||||
|
||||
## Managing Passwords
|
||||
|
||||
Duplicacy will attempt to retrieve in three ways the storage password and the storage-specific access tokens/keys.
|
||||
|
||||
* If a secret vault service is available, Duplicacy will store passwords/keys entered by the user in such a secret vault and later retrieve them when needed. On Mac OS X it is Keychain, and on Linux it is gnome-keyring. On Windows the passwords/keys are encrypted and decrypted by the Data Protection API, and encrypted passwords/keys are stored in the file *.duplicacy/keyring*. However, if the -no-save-password option is specified for the storage, then Duplicacy will not save passwords this way.
|
||||
* If an environment variable for a password is provided, Duplicacy will always take it. The table below shows the name of the environment variable for each kind of password. Note that if the storage is not the default one, the storage name will be included in the name of the environment variable.
|
||||
* If a matching key and its value are saved to the preference file (.duplicacy/preferences) by the *set* command, the value will be used as the password. The last column in the table below lists the name of the preference key for each type of password.
|
||||
|
||||
| password type | environment variable (default storage) | environment variable (non-default storage) | key in preferences |
|
||||
|:----------------:|:----------------:|:----------------:|:----------------:|
|
||||
| storage password | DUPLICACY_PASSWORD | DUPLICACY_<STORAGENAME>_PASSWORD | password |
|
||||
| sftp password | DUPLICACY_SSH_PASSWORD | DUPLICACY_<STORAGENAME>_SSH_PASSWORD | ssh_password |
|
||||
| sftp key file | DUPLICACY_SSH_KEY_FILE | DUPLICACY_<STORAGENAME>_SSH_KEY_FILE | ssh_key_file |
|
||||
| Dropbox Token | DUPLICACY_DROPBOX_TOKEN | DUPLICACY_<STORAGENAME>>_DROPBOX_TOKEN | dropbox_token |
|
||||
| S3 Access ID | DUPLICACY_S3_ID | DUPLICACY_<STORAGENAME>_S3_ID | s3_id |
|
||||
| S3 Secret Key | DUPLICACY_S3_SECRET | DUPLICACY_<STORAGENAME>_S3_SECRET | s3_secret |
|
||||
| BackBlaze Account ID | DUPLICACY_B2_ID | DUPLICACY_<STORAGENAME>_B2_ID | b2_id |
|
||||
| Backblaze Application Key | DUPLICACY_B2_KEY | DUPLICACY_<STORAGENAME>_B2_KEY | b2_key |
|
||||
| Azure Access Key | DUPLICACY_AZURE_KEY | DUPLICACY_<STORAGENAME>_AZURE_KEY | azure_key |
|
||||
| Google Drive Token File | DUPLICACY_GCD_TOKEN | DUPLICACY_<STORAGENAME>_GCD_TOKEN | gcd_token |
|
||||
| Microsoft OneDrive Token File | DUPLICACY_ONE_TOKEN | DUPLICACY_<STORAGENAME>_ONE_TOKEN | one_token |
|
||||
| Hubic Token File | DUPLICACY_HUBIC_TOKEN | DUPLICACY_<STORAGENAME>_HUBIC_TOKEN | hubic_token |
|
||||
|
||||
Note that the passwords stored in the environment variable and the preference need to be in plaintext and thus are insecure and should be avoided whenever possible.
|
||||
|
||||
## Scripts
|
||||
|
||||
You can instruct Duplicacy to run a script before or after executing a command. For example, if you create a bash script with the name *pre-prune* under the *.duplicacy/scripts* directory, this bash script will be run before the *prune* command starts. A script named *post-prune* will be run after the *prune* command finishes. This rule applies to all commands except *init*.
|
||||
All documentation has been moved to our wiki page:
|
||||
|
||||
* Commands
|
||||
* [init](https://github.com/gilbertchen/duplicacy/wiki/init)
|
||||
* [backup](https://github.com/gilbertchen/duplicacy/wiki/backup)
|
||||
* [restore](https://github.com/gilbertchen/duplicacy/wiki/restore)
|
||||
* [list](https://github.com/gilbertchen/duplicacy/wiki/list)
|
||||
* [check](https://github.com/gilbertchen/duplicacy/wiki/check)
|
||||
* [prune](https://github.com/gilbertchen/duplicacy/wiki/prune)
|
||||
* [cat](https://github.com/gilbertchen/duplicacy/wiki/cat)
|
||||
* [history](https://github.com/gilbertchen/duplicacy/wiki/history)
|
||||
* [diff](https://github.com/gilbertchen/duplicacy/wiki/diff)
|
||||
* [password](https://github.com/gilbertchen/duplicacy/wiki/password)
|
||||
* [add](https://github.com/gilbertchen/duplicacy/wiki/add)
|
||||
* [set](https://github.com/gilbertchen/duplicacy/wiki/set)
|
||||
* [copy](https://github.com/gilbertchen/duplicacy/wiki/copy)
|
||||
* [Include/Exclude Patterns](https://github.com/gilbertchen/duplicacy/wiki/Include-Exclude-Patterns)
|
||||
* [Managing Passwords](https://github.com/gilbertchen/duplicacy/wiki/Managing-Passwords)
|
||||
* [Cache](https://github.com/gilbertchen/duplicacy/wiki/Cache)
|
||||
* [Pre-Command and Post-Command Scripts](https://github.com/gilbertchen/duplicacy/wiki/Pre-Command-and-Post-Command-Scripts)
|
||||
|
||||
230
Gopkg.lock
generated
Normal file
230
Gopkg.lock
generated
Normal file
@@ -0,0 +1,230 @@
|
||||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||
|
||||
|
||||
[[projects]]
|
||||
name = "cloud.google.com/go"
|
||||
packages = ["compute/metadata","iam","internal","internal/optional","internal/version","storage"]
|
||||
revision = "2d3a6656c17a60b0815b7e06ab0be04eacb6e613"
|
||||
version = "v0.16.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/Azure/azure-sdk-for-go"
|
||||
packages = ["version"]
|
||||
revision = "b7fadebe0e7f5c5720986080a01495bd8d27be37"
|
||||
version = "v14.2.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/Azure/go-autorest"
|
||||
packages = ["autorest","autorest/adal","autorest/azure","autorest/date"]
|
||||
revision = "0ae36a9e544696de46fdadb7b0d5fb38af48c063"
|
||||
version = "v10.2.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/aryann/difflib"
|
||||
packages = ["."]
|
||||
revision = "e206f873d14a916d3d26c40ab667bca123f365a3"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/aws/aws-sdk-go"
|
||||
packages = ["aws","aws/awserr","aws/awsutil","aws/client","aws/client/metadata","aws/corehandlers","aws/credentials","aws/credentials/ec2rolecreds","aws/credentials/endpointcreds","aws/credentials/stscreds","aws/defaults","aws/ec2metadata","aws/endpoints","aws/request","aws/session","aws/signer/v4","internal/shareddefaults","private/protocol","private/protocol/query","private/protocol/query/queryutil","private/protocol/rest","private/protocol/restxml","private/protocol/xml/xmlutil","service/s3","service/sts"]
|
||||
revision = "a32b1dcd091264b5dee7b386149b6cc3823395c9"
|
||||
version = "v1.12.31"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/bkaradzic/go-lz4"
|
||||
packages = ["."]
|
||||
revision = "74ddf82598bc4745b965729e9c6a463bedd33049"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/dgrijalva/jwt-go"
|
||||
packages = ["."]
|
||||
revision = "dbeaa9332f19a944acb5736b4456cfcc02140e29"
|
||||
version = "v3.1.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/azure-sdk-for-go"
|
||||
packages = ["storage"]
|
||||
revision = "bbf89bd4d716c184f158d1e1428c2dbef4a18307"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/cli"
|
||||
packages = ["."]
|
||||
revision = "1de0a1836ce9c3ae1bf737a0869c4f04f28a7f98"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/go-dropbox"
|
||||
packages = ["."]
|
||||
revision = "90711b603312b1f973f3a5da3793ac4f1e5c2f2a"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gilbertchen/go-ole"
|
||||
packages = ["."]
|
||||
revision = "0e87ea779d9deb219633b828a023b32e1244dd57"
|
||||
version = "v1.2.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/go.dbus"
|
||||
packages = ["."]
|
||||
revision = "9e442e6378618c083fd3b85b703ffd202721fb17"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/goamz"
|
||||
packages = ["aws","s3"]
|
||||
revision = "eada9f4e8cc2a45db775dee08a2c37597ce4760a"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/gopass"
|
||||
packages = ["."]
|
||||
revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/keyring"
|
||||
packages = ["."]
|
||||
revision = "8855f5632086e51468cd7ce91056f8da69687ef6"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/xattr"
|
||||
packages = ["."]
|
||||
revision = "68e7a6806b0137a396d7d05601d7403ae1abac58"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/go-ini/ini"
|
||||
packages = ["."]
|
||||
revision = "32e4c1e6bc4e7d0d8451aa6b75200d19e37a536a"
|
||||
version = "v1.32.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/golang/protobuf"
|
||||
packages = ["proto","protoc-gen-go/descriptor","ptypes","ptypes/any","ptypes/duration","ptypes/timestamp"]
|
||||
revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/googleapis/gax-go"
|
||||
packages = ["."]
|
||||
revision = "317e0006254c44a0ac427cc52a0e083ff0b9622f"
|
||||
version = "v2.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/jmespath/go-jmespath"
|
||||
packages = ["."]
|
||||
revision = "0b12d6b5"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/kr/fs"
|
||||
packages = ["."]
|
||||
revision = "2788f0dbd16903de03cb8186e5c7d97b69ad387b"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/marstr/guid"
|
||||
packages = ["."]
|
||||
revision = "8bd9a64bf37eb297b492a4101fb28e80ac0b290f"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/minio/blake2b-simd"
|
||||
packages = ["."]
|
||||
revision = "3f5f724cb5b182a5c278d6d3d55b40e7f8c2efb4"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/ncw/swift"
|
||||
packages = ["."]
|
||||
revision = "ae9f0ea1605b9aa6434ed5c731ca35d83ba67c55"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pkg/errors"
|
||||
packages = ["."]
|
||||
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
|
||||
version = "v0.8.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pkg/sftp"
|
||||
packages = ["."]
|
||||
revision = "98203f5a8333288eb3163b7c667d4260fe1333e9"
|
||||
version = "1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/satori/go.uuid"
|
||||
packages = ["."]
|
||||
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
|
||||
version = "v1.2.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/vaughan0/go-ini"
|
||||
packages = ["."]
|
||||
revision = "a98ad7ee00ec53921f08832bc06ecf7fd600e6a1"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
packages = ["curve25519","ed25519","ed25519/internal/edwards25519","pbkdf2","ssh","ssh/agent","ssh/terminal"]
|
||||
revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/net"
|
||||
packages = ["context","context/ctxhttp","http2","http2/hpack","idna","internal/timeseries","lex/httplex","trace"]
|
||||
revision = "9dfe39835686865bff950a07b394c12a98ddc811"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/oauth2"
|
||||
packages = [".","google","internal","jws","jwt"]
|
||||
revision = "f95fa95eaa936d9d87489b15d1d18b97c1ba9c28"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/sys"
|
||||
packages = ["unix","windows"]
|
||||
revision = "82aafbf43bf885069dc71b7e7c2f9d7a614d47da"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/text"
|
||||
packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"]
|
||||
revision = "88f656faf3f37f690df1a32515b479415e1a6769"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "google.golang.org/api"
|
||||
packages = ["drive/v3","gensupport","googleapi","googleapi/internal/uritemplates","googleapi/transport","internal","iterator","option","storage/v1","transport/http"]
|
||||
revision = "17b5f22a248d6d3913171c1a557552ace0d9c806"
|
||||
|
||||
[[projects]]
|
||||
name = "google.golang.org/appengine"
|
||||
packages = [".","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"]
|
||||
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "google.golang.org/genproto"
|
||||
packages = ["googleapis/api/annotations","googleapis/iam/v1","googleapis/rpc/status"]
|
||||
revision = "891aceb7c239e72692819142dfca057bdcbfcb96"
|
||||
|
||||
[[projects]]
|
||||
name = "google.golang.org/grpc"
|
||||
packages = [".","balancer","balancer/roundrobin","codes","connectivity","credentials","encoding","grpclb/grpc_lb_v1/messages","grpclog","internal","keepalive","metadata","naming","peer","resolver","resolver/dns","resolver/passthrough","stats","status","tap","transport"]
|
||||
revision = "5a9f7b402fe85096d2e1d0383435ee1876e863d0"
|
||||
version = "v1.8.0"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "eff5ae2d9507f0d62cd2e5bdedebb5c59d64f70f476b087c01c35d4a5e1be72d"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
94
Gopkg.toml
Normal file
94
Gopkg.toml
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
# Gopkg.toml example
|
||||
#
|
||||
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||
# for detailed Gopkg.toml documentation.
|
||||
#
|
||||
# required = ["github.com/user/thing/cmd/thing"]
|
||||
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project"
|
||||
# version = "1.0.0"
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project2"
|
||||
# branch = "dev"
|
||||
# source = "github.com/myfork/project2"
|
||||
#
|
||||
# [[override]]
|
||||
# name = "github.com/x/y"
|
||||
# version = "2.4.0"
|
||||
|
||||
|
||||
[[constraint]]
|
||||
name = "cloud.google.com/go"
|
||||
version = "0.16.0"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/aryann/difflib"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/aws/aws-sdk-go"
|
||||
version = "1.12.31"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/bkaradzic/go-lz4"
|
||||
version = "1.0.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gilbertchen/azure-sdk-for-go"
|
||||
branch = "master"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/cli"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/go-dropbox"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gilbertchen/go-ole"
|
||||
version = "1.2.0"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/goamz"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/gopass"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/keyring"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/gilbertchen/xattr"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/minio/blake2b-simd"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/pkg/sftp"
|
||||
version = "1.0.0"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/net"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/oauth2"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "google.golang.org/api"
|
||||
24
LICENSE.md
24
LICENSE.md
@@ -1,20 +1,8 @@
|
||||
Copyright © 2017 Acrosync LLC
|
||||
|
||||
Licensor: Acrosync LLC
|
||||
|
||||
Software: Dulicacy
|
||||
|
||||
Use Limitation: 5 users
|
||||
|
||||
License Grant. Licensor hereby grants to each recipient of the Software (“you”) a non-exclusive, non-transferable, royalty-free and fully-paid-up license, under all of the Licensor’s copyright and patent rights, to use, copy, distribute, prepare derivative works of, publicly perform and display the Software, subject to the Use Limitation and the conditions set forth below.
|
||||
|
||||
Use Limitation. The license granted above allows use by up to the number of users per entity set forth above (the “Use Limitation”). For determining the number of users, “you” includes all affiliates, meaning legal entities controlling, controlled by, or under common control with you. If you exceed the Use Limitation, your use is subject to payment of Licensor’s then-current list price for licenses.
|
||||
|
||||
Conditions. Redistribution in source code or other forms must include a copy of this license document to be provided in a reasonable manner. Any redistribution of the Software is only allowed subject to this license.
|
||||
|
||||
Trademarks. This license does not grant you any right in the trademarks, service marks, brand names or logos of Licensor.
|
||||
|
||||
DISCLAIMER. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OR CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE.
|
||||
|
||||
Termination. If you violate the terms of this license, your rights will terminate automatically and will not be reinstated without the prior written consent of Licensor. Any such termination will not affect the right of others who may have received copies of the Software from you.
|
||||
|
||||
* Free for personal use or commercial trial
|
||||
* Non-trial commercial use requires per-user CLI licenses available from [duplicacy.com](https://duplicacy.com/buy) at a cost of $20 per year
|
||||
* A user is defined as the computer account that creates or edits the files to be backed up; if a backup contains files created or edited by multiple users for commercial purposes, one CLI license is required for each user
|
||||
* The computer with a valid commercial license for the GUI version may run the CLI version without a CLI license
|
||||
* CLI licenses are not required to restore or manage backups; only the backup command requires valid CLI licenses
|
||||
* Modification and redistribution are permitted, but commercial use of derivative works is subject to the same requirements of this license
|
||||
|
||||
247
README.md
247
README.md
@@ -1,210 +1,59 @@
|
||||
# Duplicacy: A lock-free deduplication cloud backup tool
|
||||
|
||||
Duplicacy is a new generation cross-platform cloud backup tool based on the idea of [Lock-Free Deduplication](https://github.com/gilbertchen/duplicacy-cli/blob/master/DESIGN.md). It is the only cloud backup tool that allows multiple computers to back up to the same storage simultaneously without using any locks (thus readily amenable to various cloud storage services).
|
||||
Duplicacy is a new generation cross-platform cloud backup tool based on the idea of [Lock-Free Deduplication](https://github.com/gilbertchen/duplicacy/wiki/Lock-Free-Deduplication).
|
||||
|
||||
The repository hosts source code, design documents, and binary releases of the command line version. There is also a Duplicacy GUI frontend built for Windows and Mac OS X available from https://duplicacy.com.
|
||||
This repository hosts source code, design documents, and binary releases of the command line version of Duplicacy. There is also a Duplicacy GUI frontend built for Windows and Mac OS X available from https://duplicacy.com.
|
||||
|
||||
There is a special edition of Duplicacy developed for VMware vSphere (ESXi) named [Vertical Backup](https://www.verticalbackup.com) that can back up virtual machine files on ESXi to local drives, network or cloud storages.
|
||||
|
||||
## Features
|
||||
|
||||
Duplicacy currently supports major cloud storage providers (Amazon S3, Google Cloud Storage, Microsoft Azure, Dropbox, Backblaze, Google Drive, Microsoft OneDrive, and Hubic) and offers all essential features of a modern backup tool:
|
||||
There are 3 core advantages of Duplicacy over any other open-source or commercial backup tools:
|
||||
|
||||
* Incremental backup: only back up what has been changed
|
||||
* Full snapshot : although each backup is incremental, it must behave like a full snapshot for easy restore and deletion
|
||||
* Deduplication: identical files must be stored as one copy (file-level deduplication), and identical parts from different files must be stored as one copy (block-level deduplication)
|
||||
* Encryption: encrypt not only file contents but also file paths, sizes, times, etc.
|
||||
* Deletion: every backup can be deleted independently without affecting others
|
||||
* Concurrent access: multiple clients can back up to the same storage at the same time
|
||||
* Snapshot migration: all or selected snapshots can be migrated from one storage to another
|
||||
* Duplicacy is the *only* cloud backup tool that allows multiple computers to back up to the same cloud storage, taking advantage of cross-computer deduplication whenever possible, without direct communication among them. This feature literally turns any cloud storage server supporting only a basic set of file operations into a sophisticated deduplication-aware server.
|
||||
|
||||
The key idea of **Lock-Free Deduplication** can be summarized as follows:
|
||||
* Unlike other chunk-based backup tools where chunks are grouped into pack files and a chunk database is used to track which chunks are stored inside each pack file, Duplicacy takes a database-less approach where every chunk is saved independently using its hash as the file name to facilitate quick lookups. The lack of a centralized chunk database not only makes the implementation less error-prone, but also produces a highly maintainable piece of software with plenty of room for development of new features and usability enhancements.
|
||||
|
||||
* Use variable-size chunking algorithm to split files into chunks
|
||||
* Store each chunk in the storage using a file name derived from its hash, and rely on the file system API to manage chunks without using a centralized indexing database
|
||||
* Apply a *two-step fossil collection* algorithm to remove chunks that become unreferenced after a backup is deleted
|
||||
* Duplicacy is fast. While the performance wasn't the top-priority design goal, Duplicacy has been shown to outperform other backup tools by a considerable margin, as indicated by the following results obtained from a [benchmarking experiment](https://github.com/gilbertchen/benchmarking) backing up the [Linux code base](https://github.com/torvalds/linux) using Duplicacy and 3 other open-source backup tools.
|
||||
|
||||
The [design document](https://github.com/gilbertchen/duplicacy-cli/blob/master/DESIGN.md) explains lock-free deduplication in detail.
|
||||
[](https://github.com/gilbertchen/benchmarking)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Duplicacy is written in Go. You can run the following command to build the executable (which will be created under `$GOPATH/bin`):
|
||||
|
||||
```
|
||||
go get -u github.com/gilbertchen/duplicacy/...
|
||||
```
|
||||
|
||||
You can also visit the [releases page](https://github.com/gilbertchen/duplicacy-cli/releases/latest) to download the pre-built binary suitable for your platform..
|
||||
|
||||
Once you have the Duplicacy executable on your path, you can change to the directory that you want to back up (called *repository*) and run the *init* command:
|
||||
|
||||
```
|
||||
$ cd path/to/your/repository
|
||||
$ duplicacy init mywork sftp://user@192.168.1.100/path/to/storage
|
||||
```
|
||||
|
||||
This *init* command connects the repository with the remote storage at 192.168.1.00 via SFTP. It will initialize the remote storage if this has not been done before. It also assigns the snapshot id *mywork* to the repository. This snapshot id is used to uniquely identify this repository if there are other repositories that also back up to the same storage.
|
||||
|
||||
You can now create snapshots of the repository by invoking the *backup* command. The first snapshot may take a while depending on the size of the repository and the upload bandwidth. Subsequent snapshots will be much faster, as only new or modified files will be uploaded. Each snapshot is identified by the snapshot id and an increasing revision number starting from 1.
|
||||
|
||||
```sh
|
||||
$ duplicacy backup -stats
|
||||
```
|
||||
|
||||
Duplicacy provides a set of commands, such as list, check, diff, cat history, to manage snapshots:
|
||||
|
||||
```makefile
|
||||
$ duplicacy list # List all snapshots
|
||||
$ duplicacy check # Check integrity of snapshots
|
||||
$ duplicacy diff # Compare two snapshots, or the same file in two snapshots
|
||||
$ duplicacy cat # Print a file in a snapshot
|
||||
$ duplicacy history # Show how a file changes over time
|
||||
```
|
||||
|
||||
The *restore* command rolls back the repository to a previous revision:
|
||||
```sh
|
||||
$ duplicacy restore -r 1
|
||||
```
|
||||
|
||||
The *prune* command removes snapshots by revisions, or tags, or retention policies:
|
||||
|
||||
```sh
|
||||
$ duplicacy prune -r 1 # Remove the snapshot with revision number 1
|
||||
$ duplicacy prune -t quick # Remove all snapshots with the tag 'quick'
|
||||
$ duplicacy prune -keep 1:7 # Keep 1 snapshot per day for snapshots older than 7 days
|
||||
$ duplicacy prune -keep 7:30 # Keep 1 snapshot every 7 days for snapshots older than 30 days
|
||||
$ duplicacy prune -keep 0:180 # Remove all snapshots older than 180 days
|
||||
```
|
||||
|
||||
The first time the *prune* command is called, it removes the specified snapshots but keeps all unreferenced chunks as fossils.
|
||||
Since it uses the two-step fossil collection algorithm to clean chunks, you will need to run it again to remove those fossils from the storage:
|
||||
|
||||
```sh
|
||||
$ duplicacy prune # Chunks from deleted snapshots will be removed if deletion criteria are met
|
||||
```
|
||||
|
||||
To back up to multiple storages, use the *add* command to add a new storage. The *add* command is similar to the *init* command, except that the first argument is a storage name used to distinguish different storages:
|
||||
|
||||
```sh
|
||||
$ duplicacy add s3 mywork s3://amazon.com/mybucket/path/to/storage
|
||||
```
|
||||
|
||||
You can back up to any storage by specifying the storage name:
|
||||
|
||||
```sh
|
||||
$ duplicacy backup -storage s3
|
||||
```
|
||||
|
||||
However, snapshots created this way will be different on different storages, if the repository has been changed during two backup operations. A better approach, is to use the *copy* command to copy specified snapshots from one storage to another:
|
||||
|
||||
```sh
|
||||
$ duplicacy copy -r 1 -to s3 # Copy snapshot at revision 1 to the s3 storage
|
||||
$ duplicacy copy -to s3 # Copy every snapshot to the s3 storage
|
||||
```
|
||||
|
||||
The [User Guide](https://github.com/gilbertchen/duplicacy-cli/blob/master/GUIDE.md) contains a complete reference to
|
||||
all commands and other features of Duplicacy.
|
||||
* [A brief introduction](https://github.com/gilbertchen/duplicacy/wiki/Quick-Start)
|
||||
* [Command references](https://github.com/gilbertchen/duplicacy/wiki)
|
||||
* [Building from source](https://github.com/gilbertchen/duplicacy/wiki/Installation)
|
||||
|
||||
## Storages
|
||||
|
||||
Duplicacy currently supports local file storage, SFTP, and 5 cloud storage providers.
|
||||
Duplicacy currently provides the following storage backends:
|
||||
|
||||
#### Local disk
|
||||
* Local disk
|
||||
* SFTP
|
||||
* Dropbox
|
||||
* Amazon S3
|
||||
* Wasabi
|
||||
* DigitalOcean Spaces
|
||||
* Google Cloud Storage
|
||||
* Microsoft Azure
|
||||
* Backblaze B2
|
||||
* Google Drive
|
||||
* Microsoft OneDrive
|
||||
* Hubic
|
||||
* OpenStack Swift
|
||||
* WebDAV (under beta testing)
|
||||
* pcloud (via WebDAV)
|
||||
* Box.com (via WebDAV)
|
||||
|
||||
```
|
||||
Storage URL: /path/to/storage (on Linux or Mac OS X)
|
||||
C:\path\to\storage (on Windows)
|
||||
```
|
||||
Please consult the [wiki page](https://github.com/gilbertchen/duplicacy/wiki/Storage-Backends) on how to set up Duplicacy to work with each cloud storage.
|
||||
|
||||
#### SFTP
|
||||
|
||||
```
|
||||
Storage URL: sftp://username@server/path/to/storage
|
||||
```
|
||||
|
||||
Login methods include password authentication and public key authentication. Due to a limitation of the underlying Go SSH library, the key pair for public key authentication must be generated without a passphrase. To work with a key that has a passphrase, you can set up SSH agent forwarding which is also supported by Duplicacy.
|
||||
|
||||
#### Dropbox
|
||||
|
||||
```
|
||||
Storage URL: dropbox://path/to/storage
|
||||
```
|
||||
|
||||
For Duplicacy to access your Dropbox storage, you must provide an access token that can be obtained in one of two ways:
|
||||
|
||||
* Create your own app on the [Dropbox Developer](https://www.dropbox.com/developers) page, and then generate the [access token](https://blogs.dropbox.com/developers/2014/05/generate-an-access-token-for-your-own-account/)
|
||||
|
||||
* Or authorize Duplicacy to access its app folder inside your Dropbox (following [this link](https://dl.dropboxusercontent.com/u/95866350/start_dropbox_token.html)), and Dropbox will generate the access token (which is not visible to us, as the redirect page showing the token is merely a static html hosted by Dropbox)
|
||||
|
||||
Dropbox has two advantages over other cloud providers. First, if you are already a paid user then to use the unused space as the backup storage is basically free. Second, unlike other providers Dropbox does not charge bandwidth or API usage fees.
|
||||
|
||||
#### Amazon S3
|
||||
|
||||
```
|
||||
Storage URL: s3://amazon.com/bucket/path/to/storage (default region is us-east-1)
|
||||
s3://region@amazon.com/bucket/path/to/storage (other regions must be specified)
|
||||
```
|
||||
|
||||
You'll need to input an access key and a secret key to access your Amazon S3 storage.
|
||||
For reference, the following chart shows the running times (in seconds) of backing up the [Linux code base](https://github.com/torvalds/linux) to each of those supported storages:
|
||||
|
||||
|
||||
#### Google Cloud Storage
|
||||
[](https://github.com/gilbertchen/cloud-storage-comparison)
|
||||
|
||||
```
|
||||
Storage URL: gcs://bucket/path/to/storage
|
||||
```
|
||||
|
||||
Starting from version 2.0.0, a new Google Cloud Storage backend is added which is implemented using the [official Google client library](https://godoc.org/cloud.google.com/go/storage). You must first obtain a credential file by [authorizing](https://duplicacy.com/gcp_start) Duplicacy to access your Google Cloud Storage account or by [downloading](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts) a service account credential file.
|
||||
|
||||
You can also use the s3 protocol to access Google Cloud Storage. To do this, you must enable the [s3 interoperability](https://cloud.google.com/storage/docs/migrating#migration-simple) in your Google Cloud Storage settings and set the storage url as `s3://storage.googleapis.com/bucket/path/to/storage`.
|
||||
|
||||
#### Microsoft Azure
|
||||
|
||||
```
|
||||
Storage URL: azure://account/container
|
||||
```
|
||||
|
||||
You'll need to input the access key once prompted.
|
||||
|
||||
#### Backblaze
|
||||
|
||||
```
|
||||
Storage URL: b2://bucket
|
||||
```
|
||||
|
||||
You'll need to input the account id and application key.
|
||||
|
||||
Backblaze's B2 storage is not only the least expensive (at 0.5 cent per GB per month), but also the fastest. We have been working closely with their developers to leverage the full potentials provided by the B2 API in order to maximize the transfer speed.
|
||||
|
||||
#### Google Drive
|
||||
|
||||
```
|
||||
Storage URL: gcd://path/to/storage
|
||||
```
|
||||
|
||||
To use Google Drive as the storage, you first need to download a token file from https://duplicacy.com/gcd_start by
|
||||
authorizing Duplicacy to access your Google Drive, and then enter the path to this token file to Duplicacy when prompted.
|
||||
|
||||
#### Microsoft OneDrive
|
||||
|
||||
```
|
||||
Storage URL: one://path/to/storage
|
||||
```
|
||||
|
||||
To use Microsoft OneDrive as the storage, you first need to download a token file from https://duplicacy.com/one_start by
|
||||
authorizing Duplicacy to access your OneDrive, and then enter the path to this token file to Duplicacy when prompted.
|
||||
|
||||
#### Hubic
|
||||
|
||||
```
|
||||
Storage URL: hubic://path/to/storage
|
||||
```
|
||||
|
||||
To use Hubic as the storage, you first need to download a token file from https://duplicacy.com/hubic_start by
|
||||
authorizing Duplicacy to access your Hubic drive, and then enter the path to this token file to Duplicacy when prompted.
|
||||
|
||||
Hubic offers the most free space (25GB) of all major cloud providers and there is no bandwidth charge (same as Google Drive and OneDrive), so it may be worth a try.
|
||||
|
||||
For complete benchmark results please visit https://github.com/gilbertchen/cloud-storage-comparison.
|
||||
|
||||
## Comparison with Other Backup Tools
|
||||
|
||||
@@ -213,38 +62,36 @@ to find the differences from previous backups and only then uploading the differ
|
||||
|
||||
[bup](https://github.com/bup/bup) also uses librsync to split files into chunks but save chunks in the git packfile format. It doesn't support any cloud storage, or deletion of old backups.
|
||||
|
||||
[Obnam](http://obnam.org) got the incremental backup model right in the sense that every incremental backup is actually a full snapshot. Although Obnam also splits files into chunks, it does not adopt either the rsync algorithm or the variable-size chunking algorithm. As a result, deletions or insertions of a few bytes will foil the
|
||||
[deduplication](http://obnam.org/faq/dedup).
|
||||
Deletion of old backups is possible, but no cloud storages are supported.
|
||||
Multiple clients can back up to the same storage, but only sequential access is granted by the [locking on-disk data structures](http://obnam.org/locking/).
|
||||
It is unclear if the lack of cloud backends is due to difficulties in porting the locking data structures to cloud storage APIs.
|
||||
[Duplicati](https://duplicati.com) is one of the first backup tools that adopt the chunk-based approach to split files into chunks which are then uploaded to the storage. The chunk-based approach got the incremental backup model right in the sense that every incremental backup is actually a full snapshot. As Duplicati splits files into fixed-size chunks, deletions or insertions of a few bytes will foil the deduplication. Cloud support is extensive, but multiple clients can't back up to the same storage location.
|
||||
|
||||
[Attic](https://attic-backup.org) has been acclaimed by some as the [Holy Grail of backups](https://www.stavros.io/posts/holy-grail-backups). It follows the same incremental backup model as Obnam, but embraces the variable-size chunk algorithm for better performance and better deduplication. Deletions of old backup is also supported. However, no cloud backends are implemented, as in Obnam. Although concurrent backups from multiple clients to the same storage is in theory possible by the use of locking, it is
|
||||
[Attic](https://attic-backup.org) has been acclaimed by some as the [Holy Grail of backups](https://www.stavros.io/posts/holy-grail-backups). It follows the same incremental backup model like Duplicati, but embraces the variable-size chunk algorithm for better performance and higher deduplication efficiency (not susceptible to byte insertion and deletion any more). Deletions of old backup is also supported. However, no cloud backends are implemented. Although concurrent backups from multiple clients to the same storage is in theory possible by the use of locking, it is
|
||||
[not recommended](http://librelist.com/browser//attic/2014/11/11/backing-up-multiple-servers-into-a-single-repository/#e96345aa5a3469a87786675d65da492b) by the developer due to chunk indices being kept in a local cache.
|
||||
Concurrent access is not only a convenience; it is a necessity for better deduplication. For instance, if multiple machines with the same OS installed can back up their entire drives to the same storage, only one copy of the system files needs to be stored, greatly reducing the storage space regardless of the number of machines. Attic still adopts the traditional approach of using a centralized indexing database to manage chunks, and relies heavily on caching to improve performance. The presence of exclusive locking makes it hard to be adapted for cloud storage APIs and reduces the level of deduplication.
|
||||
Concurrent access is not only a convenience; it is a necessity for better deduplication. For instance, if multiple machines with the same OS installed can back up their entire drives to the same storage, only one copy of the system files needs to be stored, greatly reducing the storage space regardless of the number of machines. Attic still adopts the traditional approach of using a centralized indexing database to manage chunks, and relies heavily on caching to improve performance. The presence of exclusive locking makes it hard to be extended to cloud storages.
|
||||
|
||||
[restic](https://restic.github.io) is a more recent addition. It uses a format similar to the git packfile format. Multiple clients backing up to the same storage are still guarded by
|
||||
[locks](https://github.com/restic/restic/blob/master/doc/Design.md#locks), and because a chunk database is used, deduplication isn't real-time (different clients sharing the same files will upload different copies of the same chunks). A prune operation will completely block all other clients connected to the storage from doing their regular backups. Moreover, since most cloud storage services do not provide a locking service, the best effort is to use some basic file operations to simulate a lock, but distributed locking is known to be a hard problem and it is unclear how reliable restic's lock implementation is. A faulty implementation may cause a prune operation to accidentally delete data still in use, resulting in unrecoverable data loss. This is the exact problem that we avoided by taking the lock-free approach.
|
||||
|
||||
[restic](https://restic.github.io) is a more recent addition. It is worth mentioning here because, like Duplicacy, it is written in Go. It uses a format similar to the git packfile format, but not exactly the same. Multiple clients backing up to the same storage are still guarded by
|
||||
[locks](https://github.com/restic/restic/blob/master/doc/Design.md#locks).
|
||||
A command to delete old backups is in the developer's [plan](https://github.com/restic/restic/issues/18). S3 storage is supported, although it is unclear how hard it is to support other cloud storage APIs because of the need for locking. Overall, it still falls in the same category as Attic. Whether it will eventually reach the same level as Attic remains to be seen.
|
||||
|
||||
The following table compares the feature lists of all these backup tools:
|
||||
|
||||
|
||||
| Feature/Tool | duplicity | bup | Obnam | Attic | restic | **Duplicacy** |
|
||||
| Feature/Tool | duplicity | bup | Duplicati | Attic | restic | **Duplicacy** |
|
||||
|:------------------:|:---------:|:---:|:-----------------:|:---------------:|:-----------------:|:-------------:|
|
||||
| Incremental Backup | Yes | Yes | Yes | Yes | Yes | **Yes** |
|
||||
| Full Snapshot | No | Yes | Yes | Yes | Yes | **Yes** |
|
||||
| Compression | Yes | Yes | Yes | Yes | No | **Yes** |
|
||||
| Deduplication | Weak | Yes | Weak | Yes | Yes | **Yes** |
|
||||
| Encryption | Yes | Yes | Yes | Yes | Yes | **Yes** |
|
||||
| Deletion | No | No | Yes | Yes | No | **Yes** |
|
||||
| Concurrent Access | No | No | Exclusive locking | Not recommended | Exclusive locking | **Lock-free** |
|
||||
| Cloud Support | Extensive | No | No | No | S3 only | **S3, GCS, Azure, Dropbox, Backblaze, Google Drive, OneDrive, and Hubic**|
|
||||
| Concurrent Access | No | No | No | Not recommended | Exclusive locking | **Lock-free** |
|
||||
| Cloud Support | Extensive | No | Extensive | No | Limited | **Extensive** |
|
||||
| Snapshot Migration | No | No | No | No | No | **Yes** |
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
Duplicacy CLI is released under the [Fair Source 5 License](https://fair.io), which means it is free for individual users or any company or organization with less than 5 users. If your company or organization has 5 or more users, then a license for the actual number of users must be purchased from [duplicacy.com](https://duplicacy.com/customer).
|
||||
|
||||
A user is defined as the owner of any files to be backed up by Duplicacy. If you are an IT administrator who uses Duplicacy to back up files for your colleagues, then each colleague will be counted in the user limit permitted by the license.
|
||||
* Free for personal use or commercial trial
|
||||
* Non-trial commercial use requires per-user CLI licenses available from [duplicacy.com](https://duplicacy.com/buy) at a cost of $20 per year
|
||||
* A user is defined as the computer account that creates or edits the files to be backed up; if a backup contains files created or edited by multiple users for commercial purposes, one CLI license is required for each user
|
||||
* The computer with a valid commercial license for the GUI version may run the CLI version without a CLI license
|
||||
* CLI licenses are not required to restore or manage backups; only the backup command requires valid CLI licenses
|
||||
* Modification and redistribution are permitted, but commercial use of derivative works is subject to the same requirements of this license
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
images/duplicacy_benchmark_cloud.png
Normal file
BIN
images/duplicacy_benchmark_cloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
images/duplicacy_benchmark_speed.png
Normal file
BIN
images/duplicacy_benchmark_speed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
31
integration_tests/copy_test.sh
Executable file
31
integration_tests/copy_test.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
. ./test_functions.sh
|
||||
|
||||
fixture
|
||||
|
||||
pushd ${TEST_REPO}
|
||||
${DUPLICACY} init integration-tests $TEST_STORAGE -c 1k
|
||||
${DUPLICACY} add -copy default secondary integration-tests $SECONDARY_STORAGE
|
||||
add_file file1
|
||||
add_file file2
|
||||
${DUPLICACY} backup
|
||||
${DUPLICACY} copy -from default -to secondary
|
||||
add_file file3
|
||||
add_file file4
|
||||
${DUPLICACY} backup
|
||||
${DUPLICACY} copy -from default -to secondary
|
||||
${DUPLICACY} check --files -stats -storage default
|
||||
${DUPLICACY} check --files -stats -storage secondary
|
||||
# Prune revisions from default storage
|
||||
${DUPLICACY} -d -v -log prune -r 1-2 -exclusive -exhaustive -storage default
|
||||
# Copy snapshot revisions from secondary back to default
|
||||
${DUPLICACY} copy -from secondary -to default
|
||||
# Check snapshot revisions again to make sure we're ok!
|
||||
${DUPLICACY} check --files -stats -storage default
|
||||
${DUPLICACY} check --files -stats -storage secondary
|
||||
# Check for orphaned or missing chunks
|
||||
${DUPLICACY} prune -exhaustive -exclusive -storage default
|
||||
${DUPLICACY} prune -exhaustive -exclusive -storage secondary
|
||||
popd
|
||||
18
integration_tests/fixed_test.sh
Executable file
18
integration_tests/fixed_test.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Sanity test for the fixed-size chunking algorithm
|
||||
|
||||
. ./test_functions.sh
|
||||
|
||||
fixture
|
||||
|
||||
pushd ${TEST_REPO}
|
||||
${DUPLICACY} init integration-tests $TEST_STORAGE -c 64 -max 64 -min 64
|
||||
|
||||
add_file file3
|
||||
add_file file4
|
||||
|
||||
|
||||
${DUPLICACY} backup
|
||||
${DUPLICACY} check --files -stats
|
||||
popd
|
||||
38
integration_tests/resume_test.sh
Executable file
38
integration_tests/resume_test.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
. ./test_functions.sh
|
||||
|
||||
fixture
|
||||
|
||||
pushd ${TEST_REPO}
|
||||
${DUPLICACY} init integration-tests $TEST_STORAGE -c 4
|
||||
|
||||
# Create 10 small files
|
||||
add_file file1 20
|
||||
add_file file2 20
|
||||
rm file3; touch file3
|
||||
add_file file4 20
|
||||
chmod u-r file4
|
||||
add_file file5 20
|
||||
add_file file6 20
|
||||
add_file file7 20
|
||||
add_file file8 20
|
||||
add_file file9 20
|
||||
add_file file10 20
|
||||
|
||||
# Fail at the 10th chunk
|
||||
env DUPLICACY_FAIL_CHUNK=10 ${DUPLICACY} backup
|
||||
|
||||
# Try it again to test the multiple-resume case
|
||||
env DUPLICACY_FAIL_CHUNK=5 ${DUPLICACY} backup
|
||||
add_file file1 20
|
||||
add_file file2 20
|
||||
|
||||
# Fail the backup before uploading the snapshot
|
||||
env DUPLICACY_FAIL_SNAPSHOT=true ${DUPLICACY} backup
|
||||
|
||||
# Now complete the backup
|
||||
${DUPLICACY} backup
|
||||
${DUPLICACY} check --files
|
||||
popd
|
||||
28
integration_tests/sparse_test.sh
Executable file
28
integration_tests/sparse_test.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Testing backup and restore of sparse files
|
||||
|
||||
. ./test_functions.sh
|
||||
|
||||
fixture
|
||||
|
||||
pushd ${TEST_REPO}
|
||||
${DUPLICACY} init integration-tests $TEST_STORAGE -c 1m
|
||||
|
||||
for i in `seq 1 10`; do
|
||||
dd if=/dev/urandom of=file3 bs=1000 count=1000 seek=$((100000 * $i))
|
||||
done
|
||||
|
||||
ls -lsh file3
|
||||
|
||||
${DUPLICACY} backup
|
||||
${DUPLICACY} check --files -stats
|
||||
|
||||
rm file1 file3
|
||||
|
||||
${DUPLICACY} restore -r 1
|
||||
${DUPLICACY} -v restore -r 1 -overwrite -stats -hash
|
||||
|
||||
ls -lsh file3
|
||||
|
||||
popd
|
||||
@@ -10,6 +10,7 @@ backup
|
||||
add_file file3
|
||||
backup
|
||||
add_file file4
|
||||
chmod u-r ${TEST_REPO}/file4
|
||||
backup
|
||||
add_file file5
|
||||
restore
|
||||
|
||||
@@ -91,8 +91,9 @@ function init_repo_pref_dir()
|
||||
function add_file()
|
||||
{
|
||||
FILE_NAME=$1
|
||||
FILE_SIZE=${2:-20000000}
|
||||
pushd ${TEST_REPO}
|
||||
dd if=/dev/urandom of=${FILE_NAME} bs=1000 count=20000
|
||||
dd if=/dev/urandom of=${FILE_NAME} bs=1 count=$(($RANDOM % ${FILE_SIZE})) &> /dev/null
|
||||
popd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"bytes"
|
||||
"sync"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"mime/multipart"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -29,6 +29,7 @@ func (err ACDError) Error() string {
|
||||
}
|
||||
|
||||
var ACDRefreshTokenURL = "https://duplicacy.com/acd_refresh"
|
||||
|
||||
type ACDClient struct {
|
||||
HTTPClient *http.Client
|
||||
|
||||
@@ -42,7 +43,6 @@ type ACDClient struct {
|
||||
TestMode bool
|
||||
}
|
||||
|
||||
|
||||
func NewACDClient(tokenFile string) (*ACDClient, error) {
|
||||
|
||||
description, err := ioutil.ReadFile(tokenFile)
|
||||
@@ -69,7 +69,7 @@ func NewACDClient(tokenFile string) (*ACDClient, error) {
|
||||
|
||||
func (client *ACDClient) call(url string, method string, input interface{}, contentType string) (io.ReadCloser, int64, error) {
|
||||
|
||||
LOG_DEBUG("ACD_CALL", "Calling %s", url)
|
||||
//LOG_DEBUG("ACD_CALL", "%s %s", method, url)
|
||||
|
||||
var response *http.Response
|
||||
|
||||
@@ -106,7 +106,7 @@ func (client *ACDClient) call(url string, method string, input interface{}, cont
|
||||
|
||||
if url != ACDRefreshTokenURL {
|
||||
client.TokenLock.Lock()
|
||||
request.Header.Set("Authorization", "Bearer " + client.Token.AccessToken)
|
||||
request.Header.Set("Authorization", "Bearer "+client.Token.AccessToken)
|
||||
client.TokenLock.Unlock()
|
||||
}
|
||||
if contentType != "" {
|
||||
@@ -126,20 +126,20 @@ func (client *ACDClient) call(url string, method string, input interface{}, cont
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.ReadFrom(response.Body)
|
||||
response.Body.Close()
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: buffer.String()}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: buffer.String()}
|
||||
}
|
||||
|
||||
if response.StatusCode == 400 {
|
||||
defer response.Body.Close()
|
||||
|
||||
e := &ACDError {
|
||||
e := &ACDError{
|
||||
Status: response.StatusCode,
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(e); err == nil {
|
||||
return nil, 0, e
|
||||
} else {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Bad input parameter"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Bad input parameter"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (client *ACDClient) call(url string, method string, input interface{}, cont
|
||||
if response.StatusCode == 401 {
|
||||
|
||||
if url == ACDRefreshTokenURL {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Unauthorized"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Unauthorized"}
|
||||
}
|
||||
|
||||
err = client.RefreshToken()
|
||||
@@ -158,15 +158,15 @@ func (client *ACDClient) call(url string, method string, input interface{}, cont
|
||||
|
||||
continue
|
||||
} else if response.StatusCode == 403 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Forbidden"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Forbidden"}
|
||||
} else if response.StatusCode == 404 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Resource not found"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Resource not found"}
|
||||
} else if response.StatusCode == 409 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Conflict"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Conflict"}
|
||||
} else if response.StatusCode == 411 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Length required"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Length required"}
|
||||
} else if response.StatusCode == 412 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Precondition failed"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Precondition failed"}
|
||||
} else if response.StatusCode == 429 || response.StatusCode == 500 {
|
||||
reason := "Too many requests"
|
||||
if response.StatusCode == 500 {
|
||||
@@ -178,9 +178,9 @@ func (client *ACDClient) call(url string, method string, input interface{}, cont
|
||||
backoff *= 2
|
||||
continue
|
||||
} else if response.StatusCode == 503 {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Service unavailable"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Service unavailable"}
|
||||
} else {
|
||||
return nil, 0, ACDError { Status: response.StatusCode, Message: "Unknown error"}
|
||||
return nil, 0, ACDError{Status: response.StatusCode, Message: "Unknown error"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ func (client *ACDClient) GetEndpoint() (err error) {
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := &ACDGetEndpointOutput {}
|
||||
output := &ACDGetEndpointOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return err
|
||||
@@ -256,7 +256,7 @@ type ACDListEntriesOutput struct {
|
||||
Entries []ACDEntry `json:"data"`
|
||||
}
|
||||
|
||||
func (client *ACDClient) ListEntries(parentID string, listFiles bool) ([]ACDEntry, error) {
|
||||
func (client *ACDClient) ListEntries(parentID string, listFiles bool, listDirectories bool) ([]ACDEntry, error) {
|
||||
|
||||
startToken := ""
|
||||
|
||||
@@ -264,20 +264,22 @@ func (client *ACDClient) ListEntries(parentID string, listFiles bool) ([]ACDEntr
|
||||
|
||||
for {
|
||||
|
||||
url := client.MetadataURL + "nodes/" + parentID + "/children?filters="
|
||||
url := client.MetadataURL + "nodes/" + parentID + "/children?"
|
||||
|
||||
if listFiles {
|
||||
url += "kind:FILE"
|
||||
} else {
|
||||
url += "kind:FOLDER"
|
||||
if listFiles && !listDirectories {
|
||||
url += "filters=kind:FILE&"
|
||||
} else if !listFiles && listDirectories {
|
||||
url += "filters=kind:FOLDER&"
|
||||
}
|
||||
|
||||
if startToken != "" {
|
||||
url += "&startToken=" + startToken
|
||||
url += "startToken=" + startToken + "&"
|
||||
}
|
||||
|
||||
if client.TestMode {
|
||||
url += "&limit=8"
|
||||
url += "limit=8"
|
||||
} else {
|
||||
url += "limit=200"
|
||||
}
|
||||
|
||||
readCloser, _, err := client.call(url, "GET", 0, "")
|
||||
@@ -287,7 +289,7 @@ func (client *ACDClient) ListEntries(parentID string, listFiles bool) ([]ACDEntr
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := &ACDListEntriesOutput {}
|
||||
output := &ACDListEntriesOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return nil, err
|
||||
@@ -321,7 +323,7 @@ func (client *ACDClient) ListByName(parentID string, name string) (string, bool,
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := &ACDListEntriesOutput {}
|
||||
output := &ACDListEntriesOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return "", false, 0, err
|
||||
@@ -351,7 +353,7 @@ func (client *ACDClient) UploadFile(parentID string, name string, content []byte
|
||||
metadata := make(map[string]interface{})
|
||||
metadata["name"] = name
|
||||
metadata["kind"] = "FILE"
|
||||
metadata["parents"] = []string{ parentID }
|
||||
metadata["parents"] = []string{parentID}
|
||||
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
@@ -389,7 +391,7 @@ func (client *ACDClient) UploadFile(parentID string, name string, content []byte
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
entry := ACDEntry {}
|
||||
entry := ACDEntry{}
|
||||
if err = json.NewDecoder(readCloser).Decode(&entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -434,7 +436,7 @@ func (client *ACDClient) CreateDirectory(parentID string, name string) (string,
|
||||
parameters := make(map[string]interface{})
|
||||
parameters["name"] = name
|
||||
parameters["kind"] = "FOLDER"
|
||||
parameters["parents"] = []string {parentID}
|
||||
parameters["parents"] = []string{parentID}
|
||||
|
||||
readCloser, _, err := client.call(url, "POST", parameters, "")
|
||||
if err != nil {
|
||||
@@ -443,7 +445,7 @@ func (client *ACDClient) CreateDirectory(parentID string, name string) (string,
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
entry := ACDEntry {}
|
||||
entry := ACDEntry{}
|
||||
if err = json.NewDecoder(readCloser).Decode(&entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"fmt"
|
||||
"testing"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
@@ -81,7 +81,7 @@ func TestACDClient(t *testing.T) {
|
||||
maxFileSize := 64 * 1024
|
||||
|
||||
for i := 0; i < numberOfFiles; i++ {
|
||||
content := make([]byte, rand.Int() % maxFileSize + 1)
|
||||
content := make([]byte, rand.Int()%maxFileSize+1)
|
||||
_, err = crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -103,7 +103,7 @@ func TestACDClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := acdClient.ListEntries(test1ID, true)
|
||||
entries, err := acdClient.ListEntries(test1ID, true, false)
|
||||
if err != nil {
|
||||
t.Errorf("Error list randomly generated files: %v", err)
|
||||
return
|
||||
@@ -117,7 +117,7 @@ func TestACDClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
entries, err = acdClient.ListEntries(test2ID, true)
|
||||
entries, err = acdClient.ListEntries(test2ID, true, false)
|
||||
if err != nil {
|
||||
t.Errorf("Error list randomly generated files: %v", err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ACDStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
client *ACDClient
|
||||
idCache map[string]string
|
||||
@@ -28,38 +29,40 @@ func CreateACDStorage(tokenFile string, storagePath string, threads int) (storag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage = &ACDStorage {
|
||||
storage = &ACDStorage{
|
||||
client: client,
|
||||
idCache: make(map[string]string),
|
||||
idCacheLock: &sync.Mutex{},
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
storagePathID, _, _, err := storage.getIDFromPath(0, storagePath)
|
||||
storagePathID, err := storage.getIDFromPath(0, storagePath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set 'storagePath' as the root of the storage and clean up the id cache accordingly
|
||||
storage.idCache = make(map[string]string)
|
||||
storage.idCache[""] = storagePathID
|
||||
|
||||
for _, dir := range []string { "chunks", "fossils", "snapshots" } {
|
||||
for _, dir := range []string{"chunks", "fossils", "snapshots"} {
|
||||
dirID, isDir, _, err := client.ListByName(storagePathID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dirID == "" {
|
||||
dirID, err = client.CreateDirectory(storagePathID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !isDir {
|
||||
return nil, fmt.Errorf("%s/%s is not a directory", storagePath + "/" + dir)
|
||||
return nil, fmt.Errorf("%s is not a directory", storagePath+"/"+dir)
|
||||
}
|
||||
storage.idCache[dir] = dirID
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
|
||||
}
|
||||
|
||||
func (storage *ACDStorage) getPathID(path string) string {
|
||||
@@ -88,56 +91,103 @@ func (storage *ACDStorage) deletePathID(path string) {
|
||||
storage.idCacheLock.Unlock()
|
||||
}
|
||||
|
||||
|
||||
func (storage *ACDStorage) convertFilePath(filePath string) (string) {
|
||||
// convertFilePath converts the path for a fossil in the form of 'chunks/id.fsl' to 'fossils/id'. This is because
|
||||
// ACD doesn't support file renaming. Instead, it only allows one file to be moved from one directory to another.
|
||||
// By adding a layer of path conversion we're pretending that we can rename between 'chunks/id' and 'chunks/id.fsl'
|
||||
func (storage *ACDStorage) convertFilePath(filePath string) string {
|
||||
if strings.HasPrefix(filePath, "chunks/") && strings.HasSuffix(filePath, ".fsl") {
|
||||
return "fossils/" + filePath[len("chunks/"):len(filePath) - len(".fsl")]
|
||||
return "fossils/" + filePath[len("chunks/"):len(filePath)-len(".fsl")]
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func (storage *ACDStorage) getIDFromPath(threadIndex int, path string) (fileID string, isDir bool, size int64, err error) {
|
||||
// getIDFromPath returns the id of the given path. If 'createDirectories' is true, create the given path and all its
|
||||
// parent directories if they don't exist. Note that if 'createDirectories' is false, it may return an empty 'fileID'
|
||||
// if the file doesn't exist.
|
||||
func (storage *ACDStorage) getIDFromPath(threadIndex int, filePath string, createDirectories bool) (fileID string, err error) {
|
||||
|
||||
if fileID, ok := storage.findPathID(filePath); ok {
|
||||
return fileID, nil
|
||||
}
|
||||
|
||||
parentID, ok := storage.findPathID("")
|
||||
if !ok {
|
||||
parentID, isDir, size, err = storage.client.ListByName("", "")
|
||||
parentID, _, _, err = storage.client.ListByName("", "")
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
return "", err
|
||||
}
|
||||
storage.savePathID("", parentID)
|
||||
}
|
||||
|
||||
names := strings.Split(path, "/")
|
||||
names := strings.Split(filePath, "/")
|
||||
current := ""
|
||||
for i, name := range names {
|
||||
parentID, isDir, _, err = storage.client.ListByName(parentID, name)
|
||||
|
||||
current = path.Join(current, name)
|
||||
fileID, ok := storage.findPathID(current)
|
||||
if ok {
|
||||
parentID = fileID
|
||||
continue
|
||||
}
|
||||
isDir := false
|
||||
fileID, isDir, _, err = storage.client.ListByName(parentID, name)
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
return "", err
|
||||
}
|
||||
if parentID == "" {
|
||||
if i == len(names) - 1 {
|
||||
return "", false, 0, nil
|
||||
if fileID == "" {
|
||||
if !createDirectories {
|
||||
return "", nil
|
||||
}
|
||||
// Create the current directory
|
||||
fileID, err = storage.client.CreateDirectory(parentID, name)
|
||||
if err != nil {
|
||||
// Check if the directory has been created by another thread
|
||||
if e, ok := err.(ACDError); !ok || e.Status != 409 {
|
||||
return "", fmt.Errorf("Failed to create directory '%s': %v", current, err)
|
||||
}
|
||||
// A 409 means the directory may have already created by another thread. Wait 10 seconds
|
||||
// until we seed the directory.
|
||||
for i := 0; i < 10; i++ {
|
||||
var createErr error
|
||||
fileID, isDir, _, createErr = storage.client.ListByName(parentID, name)
|
||||
if createErr != nil {
|
||||
return "", createErr
|
||||
}
|
||||
if fileID == "" {
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
return "", false, 0, fmt.Errorf("File path '%s' does not exist", path)
|
||||
break
|
||||
}
|
||||
}
|
||||
if i != len(names) - 1 && !isDir {
|
||||
return "", false, 0, fmt.Errorf("Invalid path %s", path)
|
||||
if fileID == "" {
|
||||
return "", fmt.Errorf("All attempts to create directory '%s' failed: %v", current, err)
|
||||
}
|
||||
} else {
|
||||
isDir = true
|
||||
}
|
||||
} else {
|
||||
storage.savePathID(current, fileID)
|
||||
}
|
||||
if i != len(names)-1 && !isDir {
|
||||
return "", fmt.Errorf("Path '%s' is not a directory", current)
|
||||
}
|
||||
parentID = fileID
|
||||
}
|
||||
|
||||
return parentID, isDir, size, err
|
||||
return parentID, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *ACDStorage) ListFiles(threadIndex int, dir string) ([]string, []int64, error) {
|
||||
var err error
|
||||
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "snapshots" {
|
||||
|
||||
entries, err := storage.client.ListEntries(storage.getPathID(dir), false)
|
||||
entries, err := storage.client.ListEntries(storage.getPathID(dir), false, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -146,7 +196,7 @@ func (storage *ACDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
|
||||
for _, entry := range entries {
|
||||
storage.savePathID(entry.Name, entry.ID)
|
||||
subDirs = append(subDirs, entry.Name + "/")
|
||||
subDirs = append(subDirs, entry.Name+"/")
|
||||
}
|
||||
return subDirs, nil, nil
|
||||
} else if strings.HasPrefix(dir, "snapshots/") {
|
||||
@@ -160,9 +210,10 @@ func (storage *ACDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
if pathID == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
storage.savePathID(dir, pathID)
|
||||
}
|
||||
|
||||
entries, err := storage.client.ListEntries(pathID, true)
|
||||
entries, err := storage.client.ListEntries(pathID, true, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -170,29 +221,40 @@ func (storage *ACDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
files := []string{}
|
||||
|
||||
for _, entry := range entries {
|
||||
storage.savePathID(dir + "/" + entry.Name, entry.ID)
|
||||
storage.savePathID(dir+"/"+entry.Name, entry.ID)
|
||||
files = append(files, entry.Name)
|
||||
}
|
||||
return files, nil, nil
|
||||
} else {
|
||||
files := []string{}
|
||||
sizes := []int64{}
|
||||
for _, parent := range []string {"chunks", "fossils" } {
|
||||
entries, err := storage.client.ListEntries(storage.getPathID(parent), true)
|
||||
parents := []string{"chunks", "fossils"}
|
||||
for i := 0; i < len(parents); i++ {
|
||||
parent := parents[i]
|
||||
pathID, ok := storage.findPathID(parent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := storage.client.ListEntries(pathID, true, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Kind != "FOLDER" {
|
||||
name := entry.Name
|
||||
if parent == "fossils" {
|
||||
name += ".fsl"
|
||||
if strings.HasPrefix(parent, "fossils") {
|
||||
name = parent + "/" + name + ".fsl"
|
||||
name = name[len("fossils/"):]
|
||||
} else {
|
||||
name = parent + "/" + name
|
||||
name = name[len("chunks/"):]
|
||||
}
|
||||
|
||||
storage.savePathID(parent + "/" + entry.Name, entry.ID)
|
||||
files = append(files, name)
|
||||
sizes = append(sizes, entry.Size)
|
||||
} else {
|
||||
parents = append(parents, parent+"/"+entry.Name)
|
||||
}
|
||||
storage.savePathID(parent+"/"+entry.Name, entry.ID)
|
||||
}
|
||||
}
|
||||
return files, sizes, nil
|
||||
@@ -203,18 +265,14 @@ func (storage *ACDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *ACDStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if !ok {
|
||||
fileID, _, _, err = storage.getIDFromPath(threadIndex, filePath)
|
||||
fileID, err := storage.getIDFromPath(threadIndex, filePath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileID == "" {
|
||||
LOG_TRACE("ACD_STORAGE", "File %s has disappeared before deletion", filePath)
|
||||
LOG_TRACE("ACD_STORAGE", "File '%s' to be deleted does not exist", filePath)
|
||||
return nil
|
||||
}
|
||||
storage.savePathID(filePath, fileID)
|
||||
}
|
||||
|
||||
err = storage.client.DeleteFile(fileID)
|
||||
if e, ok := err.(ACDError); ok && e.Status == 409 {
|
||||
@@ -234,11 +292,19 @@ func (storage *ACDStorage) MoveFile(threadIndex int, from string, to string) (er
|
||||
return fmt.Errorf("Attempting to rename file %s with unknown id", from)
|
||||
}
|
||||
|
||||
fromParentID := storage.getPathID("chunks")
|
||||
toParentID := storage.getPathID("fossils")
|
||||
fromParent := path.Dir(from)
|
||||
fromParentID, err := storage.getIDFromPath(threadIndex, fromParent, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to retrieve the id of the parent directory '%s': %v", fromParent, err)
|
||||
}
|
||||
if fromParentID == "" {
|
||||
return fmt.Errorf("The parent directory '%s' does not exist", fromParent)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(from, "fossils") {
|
||||
fromParentID, toParentID = toParentID, fromParentID
|
||||
toParent := path.Dir(to)
|
||||
toParentID, err := storage.getIDFromPath(threadIndex, toParent, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to retrieve the id of the parent directory '%s': %v", toParent, err)
|
||||
}
|
||||
|
||||
err = storage.client.MoveFile(fileID, fromParentID, toParentID)
|
||||
@@ -259,17 +325,21 @@ func (storage *ACDStorage) MoveFile(threadIndex int, from string, to string) (er
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *ACDStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "chunks" || dir == "snapshots" {
|
||||
return nil
|
||||
parentPath := path.Dir(dir)
|
||||
if parentPath == "." {
|
||||
parentPath = ""
|
||||
}
|
||||
parentID, ok := storage.findPathID(parentPath)
|
||||
if !ok {
|
||||
return fmt.Errorf("Path directory '%s' has unknown id", parentPath)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dir, "snapshots/") {
|
||||
name := dir[len("snapshots/"):]
|
||||
dirID, err := storage.client.CreateDirectory(storage.getPathID("snapshots"), name)
|
||||
name := path.Base(dir)
|
||||
dirID, err := storage.client.CreateDirectory(parentID, name)
|
||||
if err != nil {
|
||||
if e, ok := err.(ACDError); ok && e.Status == 409 {
|
||||
return nil
|
||||
@@ -278,9 +348,6 @@ func (storage *ACDStorage) CreateDirectory(threadIndex int, dir string) (err err
|
||||
}
|
||||
}
|
||||
storage.savePathID(dir, dirID)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -288,13 +355,26 @@ func (storage *ACDStorage) CreateDirectory(threadIndex int, dir string) (err err
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *ACDStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
|
||||
for len(filePath) > 0 && filePath[len(filePath) - 1] == '/' {
|
||||
filePath = filePath[:len(filePath) - 1]
|
||||
for len(filePath) > 0 && filePath[len(filePath)-1] == '/' {
|
||||
filePath = filePath[:len(filePath)-1]
|
||||
}
|
||||
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
fileID := ""
|
||||
fileID, isDir, size, err = storage.getIDFromPath(threadIndex, filePath)
|
||||
|
||||
parentPath := path.Dir(filePath)
|
||||
if parentPath == "." {
|
||||
parentPath = ""
|
||||
}
|
||||
parentID, err := storage.getIDFromPath(threadIndex, parentPath, false)
|
||||
if err != nil {
|
||||
return false, false, 0, err
|
||||
}
|
||||
if parentID == "" {
|
||||
return false, false, 0, nil
|
||||
}
|
||||
|
||||
name := path.Base(filePath)
|
||||
fileID, isDir, size, err := storage.client.ListByName(parentID, name)
|
||||
if err != nil {
|
||||
return false, false, 0, err
|
||||
}
|
||||
@@ -302,44 +382,19 @@ func (storage *ACDStorage) GetFileInfo(threadIndex int, filePath string) (exist
|
||||
return false, false, 0, nil
|
||||
}
|
||||
|
||||
storage.savePathID(filePath, fileID)
|
||||
return true, isDir, size, nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *ACDStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
parentID := ""
|
||||
filePath = "chunks/" + chunkID
|
||||
realPath := filePath
|
||||
if isFossil {
|
||||
parentID = storage.getPathID("fossils")
|
||||
filePath += ".fsl"
|
||||
realPath = "fossils/" + chunkID + ".fsl"
|
||||
} else {
|
||||
parentID = storage.getPathID("chunks")
|
||||
}
|
||||
|
||||
fileID := ""
|
||||
fileID, _, size, err = storage.client.ListByName(parentID, chunkID)
|
||||
if fileID != "" {
|
||||
storage.savePathID(realPath, fileID)
|
||||
}
|
||||
return filePath, fileID != "", size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *ACDStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if !ok {
|
||||
fileID, _, _, err = storage.getIDFromPath(threadIndex, filePath)
|
||||
fileID, err := storage.getIDFromPath(threadIndex, filePath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("File path '%s' does not exist", filePath)
|
||||
}
|
||||
storage.savePathID(filePath, fileID)
|
||||
}
|
||||
|
||||
readCloser, _, err := storage.client.DownloadFile(fileID)
|
||||
if err != nil {
|
||||
@@ -348,32 +403,26 @@ func (storage *ACDStorage) DownloadFile(threadIndex int, filePath string, chunk
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / storage.numberOfThreads)
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.numberOfThreads)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *ACDStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
parent := path.Dir(filePath)
|
||||
|
||||
if parent == "." {
|
||||
parent = ""
|
||||
}
|
||||
|
||||
parentID, ok := storage.findPathID(parent)
|
||||
|
||||
if !ok {
|
||||
parentID, _, _, err = storage.getIDFromPath(threadIndex, parent)
|
||||
parentID, err := storage.getIDFromPath(threadIndex, parent, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parentID == "" {
|
||||
return fmt.Errorf("File path '%s' does not exist", parent)
|
||||
}
|
||||
storage.savePathID(parent, parentID)
|
||||
}
|
||||
|
||||
fileID, err := storage.client.UploadFile(parentID, path.Base(filePath), content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
fileID, err := storage.client.UploadFile(parentID, path.Base(filePath), content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
if err == nil {
|
||||
storage.savePathID(filePath, fileID)
|
||||
return nil
|
||||
@@ -389,16 +438,16 @@ func (storage *ACDStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *ACDStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *ACDStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *ACDStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *ACDStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *ACDStorage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *ACDStorage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *ACDStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *ACDStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *ACDStorage) EnableTestMode() {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -12,16 +12,15 @@ import (
|
||||
)
|
||||
|
||||
type AzureStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
clients []*storage.BlobStorageClient
|
||||
container string
|
||||
containers []*storage.Container
|
||||
}
|
||||
|
||||
func CreateAzureStorage(accountName string, accountKey string,
|
||||
container string, threads int) (azureStorage *AzureStorage, err error) {
|
||||
containerName string, threads int) (azureStorage *AzureStorage, err error) {
|
||||
|
||||
var clients []*storage.BlobStorageClient
|
||||
var containers []*storage.Container
|
||||
for i := 0; i < threads; i++ {
|
||||
|
||||
client, err := storage.NewBasicClient(accountName, accountKey)
|
||||
@@ -31,23 +30,25 @@ func CreateAzureStorage(accountName string, accountKey string,
|
||||
}
|
||||
|
||||
blobService := client.GetBlobService()
|
||||
clients = append(clients, &blobService)
|
||||
container := blobService.GetContainerReference(containerName)
|
||||
containers = append(containers, container)
|
||||
}
|
||||
|
||||
exist, err := clients[0].ContainerExists(container)
|
||||
exist, err := containers[0].Exists()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !exist {
|
||||
return nil, fmt.Errorf("container %s does not exist", container)
|
||||
return nil, fmt.Errorf("container %s does not exist", containerName)
|
||||
}
|
||||
|
||||
azureStorage = &AzureStorage {
|
||||
clients: clients,
|
||||
container: container,
|
||||
azureStorage = &AzureStorage{
|
||||
containers: containers,
|
||||
}
|
||||
|
||||
azureStorage.DerivedStorage = azureStorage
|
||||
azureStorage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,12 +64,12 @@ func (azureStorage *AzureStorage) ListFiles(threadIndex int, dir string) (files
|
||||
Timeout uint
|
||||
}
|
||||
|
||||
if len(dir) > 0 && dir[len(dir) - 1] != '/' {
|
||||
if len(dir) > 0 && dir[len(dir)-1] != '/' {
|
||||
dir += "/"
|
||||
}
|
||||
dirLength := len(dir)
|
||||
|
||||
parameters := storage.ListBlobsParameters {
|
||||
parameters := storage.ListBlobsParameters{
|
||||
Prefix: dir,
|
||||
Delimiter: "",
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (azureStorage *AzureStorage) ListFiles(threadIndex int, dir string) (files
|
||||
|
||||
for {
|
||||
|
||||
results, err := azureStorage.clients[threadIndex].ListBlobs(azureStorage.container, parameters)
|
||||
results, err := azureStorage.containers[threadIndex].ListBlobs(parameters)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -85,7 +86,7 @@ func (azureStorage *AzureStorage) ListFiles(threadIndex int, dir string) (files
|
||||
if dir == "snapshots/" {
|
||||
for _, blob := range results.Blobs {
|
||||
name := strings.Split(blob.Name[dirLength:], "/")[0]
|
||||
subDirs[name + "/"] = true
|
||||
subDirs[name+"/"] = true
|
||||
}
|
||||
} else {
|
||||
for _, blob := range results.Blobs {
|
||||
@@ -115,14 +116,15 @@ func (azureStorage *AzureStorage) ListFiles(threadIndex int, dir string) (files
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *AzureStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
_, err = storage.clients[threadIndex].DeleteBlobIfExists(storage.container, filePath)
|
||||
_, err = storage.containers[threadIndex].GetBlobReference(filePath).DeleteIfExists(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveFile renames the file.
|
||||
func (storage *AzureStorage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
source := storage.clients[threadIndex].GetBlobURL(storage.container, from)
|
||||
err = storage.clients[threadIndex].CopyBlob(storage.container, to, source)
|
||||
source := storage.containers[threadIndex].GetBlobReference(from)
|
||||
destination := storage.containers[threadIndex].GetBlobReference(to)
|
||||
err = destination.Copy(source.GetURL(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -136,7 +138,8 @@ func (storage *AzureStorage) CreateDirectory(threadIndex int, dir string) (err e
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *AzureStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
properties, err := storage.clients[threadIndex].GetBlobProperties(storage.container, filePath)
|
||||
blob := storage.containers[threadIndex].GetBlobReference(filePath)
|
||||
err = blob.GetProperties(nil)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
return false, false, 0, nil
|
||||
@@ -145,58 +148,42 @@ func (storage *AzureStorage) GetFileInfo(threadIndex int, filePath string) (exis
|
||||
}
|
||||
}
|
||||
|
||||
return true, false, properties.ContentLength, nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *AzureStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
filePath = "chunks/" + chunkID
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
}
|
||||
|
||||
exist, _, size, err = storage.GetFileInfo(threadIndex, filePath)
|
||||
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
} else {
|
||||
return filePath, exist, size, err
|
||||
}
|
||||
return true, false, blob.Properties.ContentLength, nil
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *AzureStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
readCloser, err := storage.clients[threadIndex].GetBlob(storage.container, filePath)
|
||||
readCloser, err := storage.containers[threadIndex].GetBlobReference(filePath).Get(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / len(storage.clients))
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/len(storage.containers))
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *AzureStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit / len(storage.clients))
|
||||
return storage.clients[threadIndex].CreateBlockBlobFromReader(storage.container, filePath, uint64(len(content)), reader, nil)
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/len(storage.containers))
|
||||
blob := storage.containers[threadIndex].GetBlobReference(filePath)
|
||||
return blob.CreateBlockBlobFromReader(reader, nil)
|
||||
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *AzureStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *AzureStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *AzureStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *AzureStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *AzureStorage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *AzureStorage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *AzureStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *AzureStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *AzureStorage) EnableTestMode() {}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"bytes"
|
||||
"strconv"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"crypto/sha1"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type B2Error struct {
|
||||
@@ -51,7 +51,6 @@ type B2Client struct {
|
||||
UploadToken string
|
||||
|
||||
TestMode bool
|
||||
|
||||
}
|
||||
|
||||
func NewB2Client(accountID string, applicationKey string) *B2Client {
|
||||
@@ -82,40 +81,55 @@ func (client *B2Client) retry(backoff int, response *http.Response) int {
|
||||
return backoff
|
||||
}
|
||||
|
||||
func (client *B2Client) call(url string, input interface{}) (io.ReadCloser, int64, error) {
|
||||
func (client *B2Client) call(url string, method string, requestHeaders map[string]string, input interface{}) (io.ReadCloser, http.Header, int64, error) {
|
||||
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
break
|
||||
case http.MethodHead:
|
||||
break
|
||||
case http.MethodPost:
|
||||
break
|
||||
default:
|
||||
return nil, nil, 0, fmt.Errorf("unhandled http request method: " + method)
|
||||
}
|
||||
|
||||
var response *http.Response
|
||||
|
||||
backoff := 0
|
||||
for i := 0; i < 8; i++ {
|
||||
var inputReader *bytes.Reader
|
||||
method := "POST"
|
||||
|
||||
switch input.(type) {
|
||||
default:
|
||||
jsonInput, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
inputReader = bytes.NewReader(jsonInput)
|
||||
case []byte:
|
||||
inputReader = bytes.NewReader(input.([]byte))
|
||||
case int:
|
||||
method = "GET"
|
||||
inputReader = bytes.NewReader([]byte(""))
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(method, url, inputReader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
if url == B2AuthorizationURL {
|
||||
request.Header.Set("Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(client.AccountID + ":" + client.ApplicationKey)))
|
||||
request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(client.AccountID+":"+client.ApplicationKey)))
|
||||
} else {
|
||||
request.Header.Set("Authorization", client.AuthorizationToken)
|
||||
}
|
||||
|
||||
if requestHeaders != nil {
|
||||
for key, value := range requestHeaders {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if client.TestMode {
|
||||
r := rand.Float32()
|
||||
if r < 0.5 {
|
||||
@@ -132,49 +146,61 @@ func (client *B2Client) call(url string, input interface{}) (io.ReadCloser, int6
|
||||
backoff = client.retry(backoff, response)
|
||||
continue
|
||||
}
|
||||
return nil, 0, err
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
if response.StatusCode < 400 {
|
||||
return response.Body, response.ContentLength, nil
|
||||
if response.StatusCode < 300 {
|
||||
return response.Body, response.Header, response.ContentLength, nil
|
||||
}
|
||||
|
||||
LOG_DEBUG("BACKBLAZE_CALL", "URL request '%s' returned status code %d", url, response.StatusCode)
|
||||
LOG_DEBUG("BACKBLAZE_CALL", "URL request '%s %s' returned status code %d", method, url, response.StatusCode)
|
||||
|
||||
io.Copy(ioutil.Discard, response.Body)
|
||||
response.Body.Close()
|
||||
if response.StatusCode == 401 {
|
||||
if url == B2AuthorizationURL {
|
||||
return nil, 0, fmt.Errorf("Authorization failure")
|
||||
return nil, nil, 0, fmt.Errorf("Authorization failure")
|
||||
}
|
||||
client.AuthorizeAccount()
|
||||
continue
|
||||
} else if response.StatusCode == 403 {
|
||||
if !client.TestMode {
|
||||
return nil, 0, fmt.Errorf("B2 cap exceeded")
|
||||
return nil, nil, 0, fmt.Errorf("B2 cap exceeded")
|
||||
}
|
||||
continue
|
||||
} else if response.StatusCode == 404 {
|
||||
if http.MethodHead == method {
|
||||
return nil, nil, 0, nil
|
||||
}
|
||||
} else if response.StatusCode == 416 {
|
||||
if http.MethodHead == method {
|
||||
// 416 Requested Range Not Satisfiable
|
||||
return nil, nil, 0, fmt.Errorf("URL request '%s' returned status code %d", url, response.StatusCode)
|
||||
}
|
||||
} else if response.StatusCode == 429 || response.StatusCode == 408 {
|
||||
backoff = client.retry(backoff, response)
|
||||
continue
|
||||
} else if response.StatusCode >= 500 && response.StatusCode <= 599 {
|
||||
backoff = client.retry(backoff, response)
|
||||
continue
|
||||
} else {
|
||||
LOG_INFO("BACKBLAZE_CALL", "URL request '%s' returned status code %d", url, response.StatusCode)
|
||||
backoff = client.retry(backoff, response)
|
||||
continue
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
e := &B2Error {
|
||||
}
|
||||
e := &B2Error{}
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(e); err != nil {
|
||||
return nil, 0, err
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
return nil, 0, e
|
||||
return nil, nil, 0, e
|
||||
}
|
||||
|
||||
return nil, 0, fmt.Errorf("Maximum backoff reached")
|
||||
return nil, nil, 0, fmt.Errorf("Maximum backoff reached")
|
||||
}
|
||||
|
||||
type B2AuthorizeAccountOutput struct {
|
||||
@@ -186,14 +212,14 @@ type B2AuthorizeAccountOutput struct {
|
||||
|
||||
func (client *B2Client) AuthorizeAccount() (err error) {
|
||||
|
||||
readCloser, _, err := client.call(B2AuthorizationURL, make(map[string]string))
|
||||
readCloser, _, _, err := client.call(B2AuthorizationURL, http.MethodPost, nil, make(map[string]string))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := &B2AuthorizeAccountOutput {}
|
||||
output := &B2AuthorizeAccountOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return err
|
||||
@@ -220,7 +246,7 @@ func (client *B2Client) FindBucket(bucketName string) (err error) {
|
||||
|
||||
url := client.APIURL + "/b2api/v1/b2_list_buckets"
|
||||
|
||||
readCloser, _, err := client.call(url, input)
|
||||
readCloser, _, _, err := client.call(url, http.MethodPost, nil, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -285,17 +311,76 @@ func (client *B2Client) ListFileNames(startFileName string, singleFile bool, inc
|
||||
|
||||
for {
|
||||
url := client.APIURL + "/b2api/v1/b2_list_file_names"
|
||||
requestHeaders := map[string]string{}
|
||||
requestMethod := http.MethodPost
|
||||
var requestInput interface{}
|
||||
requestInput = input
|
||||
if includeVersions {
|
||||
url = client.APIURL + "/b2api/v1/b2_list_file_versions"
|
||||
} else if singleFile {
|
||||
// handle a single file with no versions as a special case to download the last byte of the file
|
||||
url = client.DownloadURL + "/file/" + client.BucketName + "/" + startFileName
|
||||
// requesting byte -1 works for empty files where 0-0 fails with a 416 error
|
||||
requestHeaders["Range"] = "bytes=-1"
|
||||
// HEAD request
|
||||
requestMethod = http.MethodHead
|
||||
requestInput = 0
|
||||
}
|
||||
readCloser, _, err := client.call(url, input)
|
||||
var readCloser io.ReadCloser
|
||||
var responseHeader http.Header
|
||||
var err error
|
||||
readCloser, responseHeader, _, err = client.call(url, requestMethod, requestHeaders, requestInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if readCloser != nil {
|
||||
defer readCloser.Close()
|
||||
}
|
||||
|
||||
output := B2ListFileNamesOutput {
|
||||
output := B2ListFileNamesOutput{}
|
||||
|
||||
if singleFile && !includeVersions {
|
||||
if responseHeader == nil {
|
||||
LOG_DEBUG("BACKBLAZE_LIST", "b2_download_file_by_name did not return headers")
|
||||
return []*B2Entry{}, nil
|
||||
}
|
||||
requiredHeaders := []string{
|
||||
"x-bz-file-id",
|
||||
"x-bz-file-name",
|
||||
}
|
||||
missingKeys := []string{}
|
||||
for _, headerKey := range requiredHeaders {
|
||||
if "" == responseHeader.Get(headerKey) {
|
||||
missingKeys = append(missingKeys, headerKey)
|
||||
}
|
||||
}
|
||||
if len(missingKeys) > 0 {
|
||||
return nil, fmt.Errorf("b2_download_file_by_name missing headers: %s", missingKeys)
|
||||
}
|
||||
// construct the B2Entry from the response headers of the download request
|
||||
fileID := responseHeader.Get("x-bz-file-id")
|
||||
fileName := responseHeader.Get("x-bz-file-name")
|
||||
fileAction := "upload"
|
||||
// byte range that is returned: "bytes #-#/#
|
||||
rangeString := responseHeader.Get("Content-Range")
|
||||
// total file size; 1 if file has content, 0 if it's empty
|
||||
lengthString := responseHeader.Get("Content-Length")
|
||||
var fileSize int64
|
||||
if "" != rangeString {
|
||||
fileSize, _ = strconv.ParseInt(rangeString[strings.Index(rangeString, "/")+1:], 0, 64)
|
||||
} else if "" != lengthString {
|
||||
// this should only execute if the requested file is empty and the range request didn't result in a Content-Range header
|
||||
fileSize, _ = strconv.ParseInt(lengthString, 0, 64)
|
||||
if fileSize != 0 {
|
||||
return nil, fmt.Errorf("b2_download_file_by_name returned non-zero file length")
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("could not parse b2_download_file_by_name headers")
|
||||
}
|
||||
fileUploadTimestamp, _ := strconv.ParseInt(responseHeader.Get("X-Bz-Upload-Timestamp"), 0, 64)
|
||||
|
||||
return []*B2Entry{&B2Entry{fileID, fileName, fileAction, fileSize, fileUploadTimestamp}}, nil
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
@@ -351,7 +436,7 @@ func (client *B2Client) DeleteFile(fileName string, fileID string) (err error) {
|
||||
input["fileId"] = fileID
|
||||
|
||||
url := client.APIURL + "/b2api/v1/b2_delete_file_version"
|
||||
readCloser, _, err := client.call(url, input)
|
||||
readCloser, _, _, err := client.call(url, http.MethodPost, make(map[string]string), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -371,14 +456,14 @@ func (client *B2Client) HideFile(fileName string) (fileID string, err error) {
|
||||
input["fileName"] = fileName
|
||||
|
||||
url := client.APIURL + "/b2api/v1/b2_hide_file"
|
||||
readCloser, _, err := client.call(url, input)
|
||||
readCloser, _, _, err := client.call(url, http.MethodPost, make(map[string]string), input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := & B2HideFileOutput {}
|
||||
output := &B2HideFileOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return "", err
|
||||
@@ -392,7 +477,8 @@ func (client *B2Client) DownloadFile(filePath string) (io.ReadCloser, int64, err
|
||||
|
||||
url := client.DownloadURL + "/file/" + client.BucketName + "/" + filePath
|
||||
|
||||
return client.call(url, 0)
|
||||
readCloser, _, len, err := client.call(url, http.MethodGet, make(map[string]string), 0)
|
||||
return readCloser, len, err
|
||||
}
|
||||
|
||||
type B2GetUploadArgumentOutput struct {
|
||||
@@ -401,19 +487,19 @@ type B2GetUploadArgumentOutput struct {
|
||||
AuthorizationToken string
|
||||
}
|
||||
|
||||
func (client *B2Client) getUploadURL() (error) {
|
||||
func (client *B2Client) getUploadURL() error {
|
||||
input := make(map[string]string)
|
||||
input["bucketId"] = client.BucketID
|
||||
|
||||
url := client.APIURL + "/b2api/v1/b2_get_upload_url"
|
||||
readCloser, _, err := client.call(url, input)
|
||||
readCloser, _, _, err := client.call(url, http.MethodPost, make(map[string]string), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := & B2GetUploadArgumentOutput {}
|
||||
output := &B2GetUploadArgumentOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return err
|
||||
@@ -427,7 +513,6 @@ func (client *B2Client) getUploadURL() (error) {
|
||||
|
||||
func (client *B2Client) UploadFile(filePath string, content []byte, rateLimit int) (err error) {
|
||||
|
||||
|
||||
hasher := sha1.New()
|
||||
hasher.Write(content)
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
@@ -487,14 +572,14 @@ func (client *B2Client) UploadFile(filePath string, content []byte, rateLimit in
|
||||
io.Copy(ioutil.Discard, response.Body)
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode < 400 {
|
||||
if response.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
LOG_DEBUG("BACKBLAZE_UPLOAD", "URL request '%s' returned status code %d", client.UploadURL, response.StatusCode)
|
||||
|
||||
if response.StatusCode == 401 {
|
||||
LOG_INFO("BACKBLAZE_UPLOAD", "Re-authorizatoin required")
|
||||
LOG_INFO("BACKBLAZE_UPLOAD", "Re-authorization required")
|
||||
client.UploadURL = ""
|
||||
client.UploadToken = ""
|
||||
continue
|
||||
@@ -513,4 +598,3 @@ func (client *B2Client) UploadFile(filePath string, content []byte, rateLimit in
|
||||
|
||||
return fmt.Errorf("Maximum backoff reached")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func createB2ClientForTest(t *testing.T) (*B2Client, string) {
|
||||
@@ -79,7 +79,7 @@ func TestB2Client(t *testing.T) {
|
||||
|
||||
maxSize := 10000
|
||||
for i := 0; i < 20; i++ {
|
||||
size := rand.Int() % maxSize + 1
|
||||
size := rand.Int()%maxSize + 1
|
||||
content := make([]byte, size)
|
||||
_, err := crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
@@ -90,7 +90,7 @@ func TestB2Client(t *testing.T) {
|
||||
hash := sha256.Sum256(content)
|
||||
name := hex.EncodeToString(hash[:])
|
||||
|
||||
err = b2Client.UploadFile(testDirectory + name, content, 100)
|
||||
err = b2Client.UploadFile(testDirectory+name, content, 100)
|
||||
if err != nil {
|
||||
t.Errorf("Error uploading file '%s': %v", name, err)
|
||||
return
|
||||
@@ -118,7 +118,7 @@ func TestB2Client(t *testing.T) {
|
||||
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if testDirectory + hash != file.FileName {
|
||||
if testDirectory+hash != file.FileName {
|
||||
t.Errorf("File %s has hash %s", file.FileName, hash)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type B2Storage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
clients []*B2Client
|
||||
}
|
||||
@@ -35,16 +35,19 @@ func CreateB2Storage(accountID string, applicationKey string, bucket string, thr
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
storage = &B2Storage {
|
||||
storage = &B2Storage{
|
||||
clients: clients,
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *B2Storage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
length := len(dir) + 1
|
||||
|
||||
@@ -65,7 +68,7 @@ func (storage *B2Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
for _, entry := range entries {
|
||||
name := entry.FileName[length:]
|
||||
subDir := strings.Split(name, "/")[0]
|
||||
subDirs[subDir + "/"] = true
|
||||
subDirs[subDir+"/"] = true
|
||||
}
|
||||
|
||||
for subDir, _ := range subDirs {
|
||||
@@ -79,7 +82,7 @@ func (storage *B2Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
}
|
||||
lastFile = entry.FileName
|
||||
if entry.Action == "hide" {
|
||||
files = append(files, entry.FileName[length:] + ".fsl")
|
||||
files = append(files, entry.FileName[length:]+".fsl")
|
||||
} else {
|
||||
files = append(files, entry.FileName[length:])
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func (storage *B2Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
func (storage *B2Storage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
|
||||
if strings.HasSuffix(filePath, ".fsl") {
|
||||
filePath = filePath[:len(filePath) - len(".fsl")]
|
||||
filePath = filePath[:len(filePath)-len(".fsl")]
|
||||
entries, err := storage.clients[threadIndex].ListFileNames(filePath, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -107,7 +110,7 @@ func (storage *B2Storage) DeleteFile(threadIndex int, filePath string) (err erro
|
||||
toBeDeleted := false
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.FileName != filePath || (!toBeDeleted && entry.Action != "hide" ) {
|
||||
if entry.FileName != filePath || (!toBeDeleted && entry.Action != "hide") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -141,12 +144,12 @@ func (storage *B2Storage) MoveFile(threadIndex int, from string, to string) (err
|
||||
|
||||
if strings.HasSuffix(from, ".fsl") {
|
||||
filePath = to
|
||||
if from != to + ".fsl" {
|
||||
if from != to+".fsl" {
|
||||
filePath = ""
|
||||
}
|
||||
} else if strings.HasSuffix(to, ".fsl") {
|
||||
filePath = from
|
||||
if to != from + ".fsl" {
|
||||
if to != from+".fsl" {
|
||||
filePath = ""
|
||||
}
|
||||
}
|
||||
@@ -182,7 +185,7 @@ func (storage *B2Storage) GetFileInfo(threadIndex int, filePath string) (exist b
|
||||
isFossil := false
|
||||
if strings.HasSuffix(filePath, ".fsl") {
|
||||
isFossil = true
|
||||
filePath = filePath[:len(filePath) - len(".fsl")]
|
||||
filePath = filePath[:len(filePath)-len(".fsl")]
|
||||
}
|
||||
|
||||
entries, err := storage.clients[threadIndex].ListFileNames(filePath, true, isFossil)
|
||||
@@ -204,20 +207,10 @@ func (storage *B2Storage) GetFileInfo(threadIndex int, filePath string) (exist b
|
||||
return true, false, entries[0].Size, nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *B2Storage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
filePath = "chunks/" + chunkID
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
}
|
||||
exist, _, size, err = storage.GetFileInfo(threadIndex, filePath)
|
||||
return filePath, exist, size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *B2Storage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
filePath = strings.Replace(filePath, " ", "%20", -1)
|
||||
readCloser, _, err := storage.clients[threadIndex].DownloadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,27 +218,28 @@ func (storage *B2Storage) DownloadFile(threadIndex int, filePath string, chunk *
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / len(storage.clients))
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/len(storage.clients))
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *B2Storage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
return storage.clients[threadIndex].UploadFile(filePath, content, storage.UploadRateLimit / len(storage.clients))
|
||||
filePath = strings.Replace(filePath, " ", "%20", -1)
|
||||
return storage.clients[threadIndex].UploadFile(filePath, content, storage.UploadRateLimit/len(storage.clients))
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *B2Storage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *B2Storage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *B2Storage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *B2Storage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *B2Storage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *B2Storage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *B2Storage) IsFastListing() (bool) { return true }
|
||||
func (storage *B2Storage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *B2Storage) EnableTestMode() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,25 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
crypto_rand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"math/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
"crypto/sha256"
|
||||
crypto_rand "crypto/rand"
|
||||
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func createRandomFile(path string, maxSize int) {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY | os.O_CREATE | os.O_TRUNC, 0644)
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
LOG_ERROR("RANDOM_FILE", "Can't open %s for writing: %v", path, err)
|
||||
return
|
||||
@@ -27,9 +27,9 @@ func createRandomFile(path string, maxSize int) {
|
||||
|
||||
defer file.Close()
|
||||
|
||||
size := maxSize / 2 + rand.Int() % (maxSize / 2)
|
||||
size := maxSize/2 + rand.Int()%(maxSize/2)
|
||||
|
||||
buffer := make([]byte, 32 * 1024)
|
||||
buffer := make([]byte, 32*1024)
|
||||
for size > 0 {
|
||||
bytes := size
|
||||
if bytes > cap(buffer) {
|
||||
@@ -65,7 +65,7 @@ func modifyFile(path string, portion float32) {
|
||||
if file != nil {
|
||||
file.Close()
|
||||
}
|
||||
} ()
|
||||
}()
|
||||
|
||||
size, err := file.Seek(0, 2)
|
||||
if err != nil {
|
||||
@@ -73,7 +73,7 @@ func modifyFile(path string, portion float32) {
|
||||
return
|
||||
}
|
||||
|
||||
length := int (float32(size) * portion)
|
||||
length := int(float32(size) * portion)
|
||||
start := rand.Int() % (int(size) - length)
|
||||
|
||||
_, err = file.Seek(int64(start), 0)
|
||||
@@ -104,6 +104,27 @@ func modifyFile(path string, portion float32) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkExistence(t *testing.T, path string, exists bool, isDir bool) {
|
||||
stat, err := os.Stat(path)
|
||||
if exists {
|
||||
if err != nil {
|
||||
t.Errorf("%s does not exist: %v", path, err)
|
||||
} else if isDir {
|
||||
if !stat.Mode().IsDir() {
|
||||
t.Errorf("%s is not a directory", path)
|
||||
}
|
||||
} else {
|
||||
if stat.Mode().IsDir() {
|
||||
t.Errorf("%s is not a file", path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err == nil || !os.IsNotExist(err) {
|
||||
t.Errorf("%s may exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncateFile(path string) {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
@@ -165,25 +186,28 @@ func TestBackupManager(t *testing.T) {
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
} ()
|
||||
}()
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test")
|
||||
os.RemoveAll(testDir)
|
||||
os.MkdirAll(testDir, 0700)
|
||||
|
||||
os.Mkdir(testDir + "/repository1", 0700)
|
||||
os.Mkdir(testDir + "/repository1/dir1", 0700)
|
||||
os.Mkdir(testDir+"/repository1", 0700)
|
||||
os.Mkdir(testDir+"/repository1/dir1", 0700)
|
||||
os.Mkdir(testDir+"/repository1/.duplicacy", 0700)
|
||||
os.Mkdir(testDir+"/repository2", 0700)
|
||||
os.Mkdir(testDir+"/repository2/.duplicacy", 0700)
|
||||
|
||||
maxFileSize := 1000000
|
||||
//maxFileSize := 200000
|
||||
|
||||
createRandomFile(testDir + "/repository1/file1", maxFileSize)
|
||||
createRandomFile(testDir + "/repository1/file2", maxFileSize)
|
||||
createRandomFile(testDir + "/repository1/dir1/file3", maxFileSize)
|
||||
createRandomFile(testDir+"/repository1/file1", maxFileSize)
|
||||
createRandomFile(testDir+"/repository1/file2", maxFileSize)
|
||||
createRandomFile(testDir+"/repository1/dir1/file3", maxFileSize)
|
||||
|
||||
threads := 1
|
||||
|
||||
storage, err := loadStorage(testDir + "/storage", threads)
|
||||
storage, err := loadStorage(testDir+"/storage", threads)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create storage: %v", err)
|
||||
return
|
||||
@@ -203,27 +227,29 @@ func TestBackupManager(t *testing.T) {
|
||||
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
if testFixedChunkSize {
|
||||
if !ConfigStorage(storage, 100, 64 * 1024, 64 * 1024, 64 * 1024, password, nil) {
|
||||
if !ConfigStorage(storage, 16384, 100, 64*1024, 64*1024, 64*1024, password, nil, false) {
|
||||
t.Errorf("Failed to initialize the storage")
|
||||
}
|
||||
} else {
|
||||
if !ConfigStorage(storage, 100, 64 * 1024, 256 * 1024, 16 * 1024, password, nil) {
|
||||
if !ConfigStorage(storage, 16384, 100, 64*1024, 256*1024, 16*1024, password, nil, false) {
|
||||
t.Errorf("Failed to initialize the storage")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
|
||||
backupManager := CreateBackupManager("host1", storage, testDir, password)
|
||||
backupManager.SetupSnapshotCache(testDir + "/repository1", "default")
|
||||
SetDuplicacyPreferencePath(testDir + "/repository1/.duplicacy")
|
||||
backupManager := CreateBackupManager("host1", storage, testDir, password, "")
|
||||
backupManager.SetupSnapshotCache("default")
|
||||
|
||||
backupManager.Backup(testDir + "/repository1", /*quickMode=*/true, threads, "first", false, false)
|
||||
SetDuplicacyPreferencePath(testDir + "/repository1/.duplicacy")
|
||||
backupManager.Backup(testDir+"/repository1" /*quickMode=*/, true, threads, "first", false, false, 0, false)
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
backupManager.Restore(testDir + "/repository2", threads, /*inPlace=*/false, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*showStatistics=*/false, /*patterns=*/nil)
|
||||
SetDuplicacyPreferencePath(testDir + "/repository2/.duplicacy")
|
||||
backupManager.Restore(testDir+"/repository2", threads, /*inPlace=*/false, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*setowner=*/false, /*showStatistics=*/false, /*patterns=*/ nil)
|
||||
|
||||
for _, f := range []string{ "file1", "file2", "dir1/file3" } {
|
||||
for _, f := range []string{"file1", "file2", "dir1/file3"} {
|
||||
if _, err := os.Stat(testDir + "/repository2/" + f); os.IsNotExist(err) {
|
||||
t.Errorf("File %s does not exist", f)
|
||||
continue
|
||||
@@ -236,16 +262,18 @@ func TestBackupManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
modifyFile(testDir + "/repository1/file1", 0.1)
|
||||
modifyFile(testDir + "/repository1/file2", 0.2)
|
||||
modifyFile(testDir + "/repository1/dir1/file3", 0.3)
|
||||
modifyFile(testDir+"/repository1/file1", 0.1)
|
||||
modifyFile(testDir+"/repository1/file2", 0.2)
|
||||
modifyFile(testDir+"/repository1/dir1/file3", 0.3)
|
||||
|
||||
backupManager.Backup(testDir + "/repository1", /*quickMode=*/true, threads, "second", false, false)
|
||||
SetDuplicacyPreferencePath(testDir + "/repository1/.duplicacy")
|
||||
backupManager.Backup(testDir+"/repository1" /*quickMode=*/, true, threads, "second", false, false, 0, false)
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
backupManager.Restore(testDir + "/repository2", 2, /*inPlace=*/true, /*quickMode=*/true, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*showStatistics=*/false, /*patterns=*/nil)
|
||||
SetDuplicacyPreferencePath(testDir + "/repository2/.duplicacy")
|
||||
backupManager.Restore(testDir+"/repository2", 2, /*inPlace=*/true, /*quickMode=*/true, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*setowner=*/false, /*showStatistics=*/false, /*patterns=*/nil)
|
||||
|
||||
for _, f := range []string{ "file1", "file2", "dir1/file3" } {
|
||||
for _, f := range []string{"file1", "file2", "dir1/file3"} {
|
||||
hash1 := getFileHash(testDir + "/repository1/" + f)
|
||||
hash2 := getFileHash(testDir + "/repository2/" + f)
|
||||
if hash1 != hash2 {
|
||||
@@ -253,13 +281,27 @@ func TestBackupManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate file2 and add a few empty directories
|
||||
truncateFile(testDir + "/repository1/file2")
|
||||
backupManager.Backup(testDir + "/repository1", /*quickMode=*/false, threads, "third", false, false)
|
||||
os.Mkdir(testDir+"/repository1/dir2", 0700)
|
||||
os.Mkdir(testDir+"/repository1/dir2/dir3", 0700)
|
||||
os.Mkdir(testDir+"/repository1/dir4", 0700)
|
||||
SetDuplicacyPreferencePath(testDir + "/repository1/.duplicacy")
|
||||
backupManager.Backup(testDir+"/repository1" /*quickMode=*/, false, threads, "third", false, false, 0, false)
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
backupManager.Restore(testDir + "/repository2", 3, /*inPlace=*/true, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*showStatistics=*/false, /*patterns=*/nil)
|
||||
|
||||
for _, f := range []string{ "file1", "file2", "dir1/file3" } {
|
||||
// Create some directories and files under repository2 that will be deleted during restore
|
||||
os.Mkdir(testDir+"/repository2/dir5", 0700)
|
||||
os.Mkdir(testDir+"/repository2/dir5/dir6", 0700)
|
||||
os.Mkdir(testDir+"/repository2/dir7", 0700)
|
||||
createRandomFile(testDir+"/repository2/file4", 100)
|
||||
createRandomFile(testDir+"/repository2/dir5/file5", 100)
|
||||
|
||||
SetDuplicacyPreferencePath(testDir + "/repository2/.duplicacy")
|
||||
backupManager.Restore(testDir+"/repository2", 3, /*inPlace=*/true, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/true, /*setowner=*/false, /*showStatistics=*/false, /*patterns=*/nil)
|
||||
|
||||
for _, f := range []string{"file1", "file2", "dir1/file3"} {
|
||||
hash1 := getFileHash(testDir + "/repository1/" + f)
|
||||
hash2 := getFileHash(testDir + "/repository2/" + f)
|
||||
if hash1 != hash2 {
|
||||
@@ -267,13 +309,26 @@ func TestBackupManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// These files/dirs should not exist because deleteMode == true
|
||||
checkExistence(t, testDir+"/repository2/dir5", false, false)
|
||||
checkExistence(t, testDir+"/repository2/dir5/dir6", false, false)
|
||||
checkExistence(t, testDir+"/repository2/dir7", false, false)
|
||||
checkExistence(t, testDir+"/repository2/file4", false, false)
|
||||
checkExistence(t, testDir+"/repository2/dir5/file5", false, false)
|
||||
|
||||
// These empty dirs should exist
|
||||
checkExistence(t, testDir+"/repository2/dir2", true, true)
|
||||
checkExistence(t, testDir+"/repository2/dir2/dir3", true, true)
|
||||
checkExistence(t, testDir+"/repository2/dir4", true, true)
|
||||
|
||||
// Remove file2 and dir1/file3 and restore them from revision 3
|
||||
os.Remove(testDir + "/repository1/file2")
|
||||
os.Remove(testDir + "/repository1/dir1/file3")
|
||||
backupManager.Restore(testDir + "/repository1", 3, /*inPlace=*/true, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*showStatistics=*/false, /*patterns=*/[]string{"+file2", "+dir1/file3", "-*"})
|
||||
SetDuplicacyPreferencePath(testDir + "/repository1/.duplicacy")
|
||||
backupManager.Restore(testDir+"/repository1", 3, /*inPlace=*/true, /*quickMode=*/false, threads, /*overwrite=*/true,
|
||||
/*deleteMode=*/false, /*setowner=*/false, /*showStatistics=*/false, /*patterns=*/[]string{"+file2", "+dir1/file3", "-*"})
|
||||
|
||||
for _, f := range []string{ "file1", "file2", "dir1/file3" } {
|
||||
for _, f := range []string{"file1", "file2", "dir1/file3"} {
|
||||
hash1 := getFileHash(testDir + "/repository1/" + f)
|
||||
hash2 := getFileHash(testDir + "/repository2/" + f)
|
||||
if hash1 != hash2 {
|
||||
@@ -281,6 +336,30 @@ func TestBackupManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
numberOfSnapshots := backupManager.SnapshotManager.ListSnapshots( /*snapshotID*/ "host1" /*revisionsToList*/, nil /*tag*/, "" /*showFiles*/, false /*showChunks*/, false)
|
||||
if numberOfSnapshots != 3 {
|
||||
t.Errorf("Expected 3 snapshots but got %d", numberOfSnapshots)
|
||||
}
|
||||
backupManager.SnapshotManager.CheckSnapshots( /*snapshotID*/ "host1" /*revisions*/, []int{1, 2, 3} /*tag*/, "",
|
||||
/*showStatistics*/ false /*showTabular*/, false /*checkFiles*/, false /*searchFossils*/, false /*resurrect*/, false)
|
||||
backupManager.SnapshotManager.PruneSnapshots("host1", "host1" /*revisions*/, []int{1} /*tags*/, nil /*retentions*/, nil,
|
||||
/*exhaustive*/ false /*exclusive=*/, false /*ignoredIDs*/, nil /*dryRun*/, false /*deleteOnly*/, false /*collectOnly*/, false, 1)
|
||||
numberOfSnapshots = backupManager.SnapshotManager.ListSnapshots( /*snapshotID*/ "host1" /*revisionsToList*/, nil /*tag*/, "" /*showFiles*/, false /*showChunks*/, false)
|
||||
if numberOfSnapshots != 2 {
|
||||
t.Errorf("Expected 2 snapshots but got %d", numberOfSnapshots)
|
||||
}
|
||||
backupManager.SnapshotManager.CheckSnapshots( /*snapshotID*/ "host1" /*revisions*/, []int{2, 3} /*tag*/, "",
|
||||
/*showStatistics*/ false /*showTabular*/, false /*checkFiles*/, false /*searchFossils*/, false /*resurrect*/, false)
|
||||
backupManager.Backup(testDir+"/repository1" /*quickMode=*/, false, threads, "fourth", false, false, 0, false)
|
||||
backupManager.SnapshotManager.PruneSnapshots("host1", "host1" /*revisions*/, nil /*tags*/, nil /*retentions*/, nil,
|
||||
/*exhaustive*/ false /*exclusive=*/, true /*ignoredIDs*/, nil /*dryRun*/, false /*deleteOnly*/, false /*collectOnly*/, false, 1)
|
||||
numberOfSnapshots = backupManager.SnapshotManager.ListSnapshots( /*snapshotID*/ "host1" /*revisionsToList*/, nil /*tag*/, "" /*showFiles*/, false /*showChunks*/, false)
|
||||
if numberOfSnapshots != 3 {
|
||||
t.Errorf("Expected 3 snapshots but got %d", numberOfSnapshots)
|
||||
}
|
||||
backupManager.SnapshotManager.CheckSnapshots( /*snapshotID*/ "host1" /*revisions*/, []int{2, 3, 4} /*tag*/, "",
|
||||
/*showStatistics*/ false /*showTabular*/, false /*checkFiles*/, false /*searchFossils*/, false /*resurrect*/, false)
|
||||
|
||||
/*buf := make([]byte, 1<<16)
|
||||
runtime.Stack(buf, true)
|
||||
fmt.Printf("%s", buf)*/
|
||||
|
||||
235
src/duplicacy_benchmark.go
Normal file
235
src/duplicacy_benchmark.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func benchmarkSplit(reader *bytes.Reader, fileSize int64, chunkSize int, compression bool, encryption bool, annotation string) {
|
||||
|
||||
config := CreateConfig()
|
||||
config.CompressionLevel = DEFAULT_COMPRESSION_LEVEL
|
||||
config.AverageChunkSize = chunkSize
|
||||
config.MaximumChunkSize = chunkSize * 4
|
||||
config.MinimumChunkSize = chunkSize / 4
|
||||
config.ChunkSeed = []byte("duplicacy")
|
||||
|
||||
config.HashKey = DEFAULT_KEY
|
||||
config.IDKey = DEFAULT_KEY
|
||||
|
||||
maker := CreateChunkMaker(config, false)
|
||||
|
||||
startTime := float64(time.Now().UnixNano()) / 1e9
|
||||
numberOfChunks := 0
|
||||
reader.Seek(0, os.SEEK_SET)
|
||||
maker.ForEachChunk(reader,
|
||||
func(chunk *Chunk, final bool) {
|
||||
if compression {
|
||||
key := ""
|
||||
if encryption {
|
||||
key = "0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
err := chunk.Encrypt([]byte(key), "")
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_ENCRYPT", "Failed to encrypt the chunk: %v", err)
|
||||
}
|
||||
}
|
||||
config.PutChunk(chunk)
|
||||
numberOfChunks++
|
||||
},
|
||||
func(size int64, hash string) (io.Reader, bool) {
|
||||
return nil, false
|
||||
})
|
||||
|
||||
runningTime := float64(time.Now().UnixNano())/1e9 - startTime
|
||||
speed := int64(float64(fileSize) / runningTime)
|
||||
LOG_INFO("BENCHMARK_SPLIT", "Split %s bytes into %d chunks %s in %.2fs: %s/s", PrettySize(fileSize), numberOfChunks, annotation,
|
||||
runningTime, PrettySize(speed))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func benchmarkRun(threads int, chunkCount int, job func(threadIndex int, chunkIndex int)) {
|
||||
indexChannel := make(chan int, chunkCount)
|
||||
stopChannel := make(chan int, threads)
|
||||
finishChannel := make(chan int, threads)
|
||||
|
||||
// Start the uploading goroutines
|
||||
for i := 0; i < threads; i++ {
|
||||
go func(threadIndex int) {
|
||||
defer CatchLogException()
|
||||
for {
|
||||
select {
|
||||
case chunkIndex := <-indexChannel:
|
||||
job(threadIndex, chunkIndex)
|
||||
finishChannel <- 0
|
||||
case <-stopChannel:
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
indexChannel <- i
|
||||
}
|
||||
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
<-finishChannel
|
||||
}
|
||||
|
||||
for i := 0; i < threads; i++ {
|
||||
stopChannel <- 0
|
||||
}
|
||||
}
|
||||
|
||||
func Benchmark(localDirectory string, storage Storage, fileSize int64, chunkSize int, chunkCount int, uploadThreads int, downloadThreads int) bool {
|
||||
|
||||
filename := filepath.Join(localDirectory, "benchmark.dat")
|
||||
|
||||
defer func() {
|
||||
os.Remove(filename)
|
||||
}()
|
||||
|
||||
LOG_INFO("BENCHMARK_GENERATE", "Generating %s byte random data in memory", PrettySize(fileSize))
|
||||
data := make([]byte, fileSize)
|
||||
_, err := rand.Read(data)
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_RAND", "Failed to generate random data: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
startTime := float64(time.Now().UnixNano()) / 1e9
|
||||
LOG_INFO("BENCHMARK_WRITE", "Writing random data to local disk")
|
||||
err = ioutil.WriteFile(filename, data, 0600)
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_WRITE", "Failed to write the random data: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
runningTime := float64(time.Now().UnixNano())/1e9 - startTime
|
||||
speed := int64(float64(fileSize) / runningTime)
|
||||
LOG_INFO("BENCHMARK_WRITE", "Wrote %s bytes in %.2fs: %s/s", PrettySize(fileSize), runningTime, PrettySize(speed))
|
||||
|
||||
startTime = float64(time.Now().UnixNano()) / 1e9
|
||||
LOG_INFO("BENCHMARK_READ", "Reading the random data from local disk")
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_OPEN", "Failed to open the random data file: %v", err)
|
||||
return false
|
||||
}
|
||||
segment := make([]byte, 1024*1024)
|
||||
for err == nil {
|
||||
_, err = file.Read(segment)
|
||||
}
|
||||
if err != io.EOF {
|
||||
LOG_ERROR("BENCHMARK_OPEN", "Failed to read the random data file: %v", err)
|
||||
return false
|
||||
}
|
||||
file.Close()
|
||||
runningTime = float64(time.Now().UnixNano())/1e9 - startTime
|
||||
speed = int64(float64(fileSize) / runningTime)
|
||||
LOG_INFO("BENCHMARK_READ", "Read %s bytes in %.2fs: %s/s", PrettySize(fileSize), runningTime, PrettySize(speed))
|
||||
|
||||
buffer := bytes.NewReader(data)
|
||||
benchmarkSplit(buffer, fileSize, chunkSize, false, false, "without compression/encryption")
|
||||
benchmarkSplit(buffer, fileSize, chunkSize, true, false, "with compression but without encryption")
|
||||
benchmarkSplit(buffer, fileSize, chunkSize, true, true, "with compression and encryption")
|
||||
|
||||
storage.CreateDirectory(0, "benchmark")
|
||||
existingFiles, _, err := storage.ListFiles(0, "benchmark/")
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_LIST", "Failed to list the benchmark directory: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
var existingChunks []string
|
||||
for _, f := range existingFiles {
|
||||
if len(f) > 0 && f[len(f)-1] != '/' {
|
||||
existingChunks = append(existingChunks, "benchmark/"+f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(existingChunks) > 0 {
|
||||
LOG_INFO("BENCHMARK_DELETE", "Deleting %d temporary files from previous benchmark runs", len(existingChunks))
|
||||
benchmarkRun(uploadThreads, len(existingChunks), func(threadIndex int, chunkIndex int) {
|
||||
storage.DeleteFile(threadIndex, existingChunks[chunkIndex])
|
||||
})
|
||||
}
|
||||
|
||||
chunks := make([][]byte, chunkCount)
|
||||
chunkHashes := make([]string, chunkCount)
|
||||
LOG_INFO("BENCHMARK_GENERATE", "Generating %d chunks", chunkCount)
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
chunks[i] = make([]byte, chunkSize)
|
||||
_, err = rand.Read(chunks[i])
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_RAND", "Failed to generate random data: %v", err)
|
||||
return false
|
||||
}
|
||||
hashInBytes := sha256.Sum256(chunks[i])
|
||||
chunkHashes[i] = hex.EncodeToString(hashInBytes[:])
|
||||
|
||||
}
|
||||
|
||||
startTime = float64(time.Now().UnixNano()) / 1e9
|
||||
benchmarkRun(uploadThreads, chunkCount, func(threadIndex int, chunkIndex int) {
|
||||
err := storage.UploadFile(threadIndex, fmt.Sprintf("benchmark/chunk%d", chunkIndex), chunks[chunkIndex])
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_UPLOAD", "Failed to upload the chunk: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
runningTime = float64(time.Now().UnixNano())/1e9 - startTime
|
||||
speed = int64(float64(chunkSize*chunkCount) / runningTime)
|
||||
LOG_INFO("BENCHMARK_UPLOAD", "Uploaded %s bytes in %.2fs: %s/s", PrettySize(int64(chunkSize*chunkCount)), runningTime, PrettySize(speed))
|
||||
|
||||
config := CreateConfig()
|
||||
|
||||
startTime = float64(time.Now().UnixNano()) / 1e9
|
||||
hashError := false
|
||||
benchmarkRun(downloadThreads, chunkCount, func(threadIndex int, chunkIndex int) {
|
||||
chunk := config.GetChunk()
|
||||
chunk.Reset(false)
|
||||
err := storage.DownloadFile(threadIndex, fmt.Sprintf("benchmark/chunk%d", chunkIndex), chunk)
|
||||
if err != nil {
|
||||
LOG_ERROR("BENCHMARK_DOWNLOAD", "Failed to download the chunk: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
hashInBytes := sha256.Sum256(chunk.GetBytes())
|
||||
hash := hex.EncodeToString(hashInBytes[:])
|
||||
if hash != chunkHashes[chunkIndex] {
|
||||
LOG_WARN("BENCHMARK_HASH", "Chunk %d has mismatched hashes: %s != %s", chunkIndex, chunkHashes[chunkIndex], hash)
|
||||
hashError = true
|
||||
}
|
||||
|
||||
config.PutChunk(chunk)
|
||||
})
|
||||
|
||||
runningTime = float64(time.Now().UnixNano())/1e9 - startTime
|
||||
speed = int64(float64(chunkSize*chunkCount) / runningTime)
|
||||
LOG_INFO("BENCHMARK_DOWNLOAD", "Downloaded %s bytes in %.2fs: %s/s", PrettySize(int64(chunkSize*chunkCount)), runningTime, PrettySize(speed))
|
||||
|
||||
if !hashError {
|
||||
benchmarkRun(uploadThreads, chunkCount, func(threadIndex int, chunkIndex int) {
|
||||
storage.DeleteFile(threadIndex, fmt.Sprintf("benchmark/chunk%d", chunkIndex))
|
||||
})
|
||||
LOG_INFO("BENCHMARK_DELETE", "Deleted %d temporary files from the storage", chunkCount)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,32 +1,34 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"bytes"
|
||||
"io"
|
||||
"runtime"
|
||||
"crypto/cipher"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"compress/zlib"
|
||||
|
||||
"github.com/bkaradzic/go-lz4"
|
||||
|
||||
)
|
||||
|
||||
// A chunk needs to acquire a new buffer and return the old one for every encrypt/decrypt operation, therefore
|
||||
// we maintain a pool of previously used buffers.
|
||||
var chunkBufferPool chan *bytes.Buffer = make(chan *bytes.Buffer, runtime.NumCPU() * 16)
|
||||
var chunkBufferPool chan *bytes.Buffer = make(chan *bytes.Buffer, runtime.NumCPU()*16)
|
||||
|
||||
func AllocateChunkBuffer() (buffer *bytes.Buffer) {
|
||||
select {
|
||||
case buffer = <- chunkBufferPool:
|
||||
case buffer = <-chunkBufferPool:
|
||||
default:
|
||||
buffer = new(bytes.Buffer)
|
||||
}
|
||||
@@ -76,9 +78,9 @@ func CreateChunk(config *Config, bufferNeeded bool) *Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
return &Chunk {
|
||||
buffer : buffer,
|
||||
config : config,
|
||||
return &Chunk{
|
||||
buffer: buffer,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ func (chunk *Chunk) GetLength() int {
|
||||
}
|
||||
|
||||
// GetBytes returns data available in this chunk
|
||||
func (chunk *Chunk) GetBytes() [] byte {
|
||||
func (chunk *Chunk) GetBytes() []byte {
|
||||
return chunk.buffer.Bytes()
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ func (chunk *Chunk) Reset(hashNeeded bool) {
|
||||
}
|
||||
|
||||
// Write implements the Writer interface.
|
||||
func (chunk *Chunk) Write(p []byte) (int, error){
|
||||
func (chunk *Chunk) Write(p []byte) (int, error) {
|
||||
|
||||
// buffer may be nil, when the chunk is used for computing the hash only.
|
||||
if chunk.buffer == nil {
|
||||
@@ -132,7 +134,7 @@ func (chunk *Chunk) Write(p []byte) (int, error){
|
||||
|
||||
// GetHash returns the chunk hash.
|
||||
func (chunk *Chunk) GetHash() string {
|
||||
if (len(chunk.hash) == 0) {
|
||||
if len(chunk.hash) == 0 {
|
||||
chunk.hash = chunk.hasher.Sum(nil)
|
||||
}
|
||||
|
||||
@@ -154,6 +156,18 @@ func (chunk *Chunk) GetID() string {
|
||||
return chunk.id
|
||||
}
|
||||
|
||||
func (chunk *Chunk) VerifyID() {
|
||||
hasher := chunk.config.NewKeyedHasher(chunk.config.HashKey)
|
||||
hasher.Write(chunk.buffer.Bytes())
|
||||
hash := hasher.Sum(nil)
|
||||
hasher = chunk.config.NewKeyedHasher(chunk.config.IDKey)
|
||||
hasher.Write([]byte(hash))
|
||||
chunkID := hex.EncodeToString(hasher.Sum(nil))
|
||||
if chunkID != chunk.GetID() {
|
||||
LOG_ERROR("CHUNK_ID", "The chunk id should be %s instead of %s, length: %d", chunkID, chunk.GetID(), len(chunk.buffer.Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt encrypts the plain data stored in the chunk buffer. If derivationKey is not nil, the actual
|
||||
// encryption key will be HMAC-SHA256(encryptionKey, derivationKey).
|
||||
func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err error) {
|
||||
@@ -167,7 +181,7 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
encryptedBuffer.Reset()
|
||||
defer func() {
|
||||
ReleaseChunkBuffer(encryptedBuffer)
|
||||
} ()
|
||||
}()
|
||||
|
||||
if len(encryptionKey) > 0 {
|
||||
|
||||
@@ -217,7 +231,7 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
if availableLength < maximumLength {
|
||||
encryptedBuffer.Grow(maximumLength - availableLength)
|
||||
}
|
||||
written, err := lz4.Encode(encryptedBuffer.Bytes()[offset + 4:], chunk.buffer.Bytes())
|
||||
written, err := lz4.Encode(encryptedBuffer.Bytes()[offset+4:], chunk.buffer.Bytes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("LZ4 compression error: %v", err)
|
||||
}
|
||||
@@ -246,7 +260,7 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
|
||||
// The encrypted data will be appended to the duplicacy header and the once.
|
||||
encryptedBytes := gcm.Seal(encryptedBuffer.Bytes()[:offset], nonce,
|
||||
encryptedBuffer.Bytes()[offset: offset + dataLength + paddingLength], nil)
|
||||
encryptedBuffer.Bytes()[offset:offset+dataLength+paddingLength], nil)
|
||||
|
||||
encryptedBuffer.Truncate(len(encryptedBytes))
|
||||
|
||||
@@ -256,6 +270,17 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
|
||||
}
|
||||
|
||||
// This is to ensure compability with Vertical Backup, which still uses HMAC-SHA256 (instead of HMAC-BLAKE2) to
|
||||
// derive the key used to encrypt/decrypt files and chunks.
|
||||
|
||||
var DecryptWithHMACSHA256 = false
|
||||
|
||||
func init() {
|
||||
if value, found := os.LookupEnv("DUPLICACY_DECRYPT_WITH_HMACSHA256"); found && value != "0" {
|
||||
DecryptWithHMACSHA256 = true
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt decrypts the encrypted data stored in the chunk buffer. If derivationKey is not nil, the actual
|
||||
// encryption key will be HMAC-SHA256(encryptionKey, derivationKey).
|
||||
func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err error) {
|
||||
@@ -266,7 +291,7 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
encryptedBuffer.Reset()
|
||||
defer func() {
|
||||
ReleaseChunkBuffer(encryptedBuffer)
|
||||
} ()
|
||||
}()
|
||||
|
||||
chunk.buffer, encryptedBuffer = encryptedBuffer, chunk.buffer
|
||||
|
||||
@@ -275,7 +300,13 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
key := encryptionKey
|
||||
|
||||
if len(derivationKey) > 0 {
|
||||
hasher := chunk.config.NewKeyedHasher([]byte(derivationKey))
|
||||
var hasher hash.Hash
|
||||
if DecryptWithHMACSHA256 {
|
||||
hasher = hmac.New(sha256.New, []byte(derivationKey))
|
||||
} else {
|
||||
hasher = chunk.config.NewKeyedHasher([]byte(derivationKey))
|
||||
}
|
||||
|
||||
hasher.Write(encryptionKey)
|
||||
key = hasher.Sum(nil)
|
||||
}
|
||||
@@ -297,15 +328,15 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
return fmt.Errorf("No enough encrypted data (%d bytes) provided", len(encryptedBuffer.Bytes()))
|
||||
}
|
||||
|
||||
if string(encryptedBuffer.Bytes()[:headerLength - 1]) != ENCRYPTION_HEADER[:headerLength - 1] {
|
||||
if string(encryptedBuffer.Bytes()[:headerLength-1]) != ENCRYPTION_HEADER[:headerLength-1] {
|
||||
return fmt.Errorf("The storage doesn't seem to be encrypted")
|
||||
}
|
||||
|
||||
if encryptedBuffer.Bytes()[headerLength - 1] != 0 {
|
||||
return fmt.Errorf("Unsupported encryption version %d", encryptedBuffer.Bytes()[headerLength - 1])
|
||||
if encryptedBuffer.Bytes()[headerLength-1] != 0 {
|
||||
return fmt.Errorf("Unsupported encryption version %d", encryptedBuffer.Bytes()[headerLength-1])
|
||||
}
|
||||
|
||||
nonce := encryptedBuffer.Bytes()[headerLength: offset]
|
||||
nonce := encryptedBuffer.Bytes()[headerLength:offset]
|
||||
|
||||
decryptedBytes, err := gcm.Open(encryptedBuffer.Bytes()[:offset], nonce,
|
||||
encryptedBuffer.Bytes()[offset:], nil)
|
||||
@@ -314,7 +345,8 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
return err
|
||||
}
|
||||
|
||||
paddingLength := int(decryptedBytes[len(decryptedBytes) - 1])
|
||||
|
||||
paddingLength := int(decryptedBytes[len(decryptedBytes)-1])
|
||||
if paddingLength == 0 {
|
||||
paddingLength = 256
|
||||
}
|
||||
@@ -323,10 +355,10 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
}
|
||||
|
||||
for i := 0; i < paddingLength; i++ {
|
||||
padding := decryptedBytes[len(decryptedBytes) - 1 - i]
|
||||
padding := decryptedBytes[len(decryptedBytes)-1-i]
|
||||
if padding != byte(paddingLength) {
|
||||
return fmt.Errorf("Incorrect padding of length %d: %x", paddingLength,
|
||||
decryptedBytes[len(decryptedBytes) - paddingLength:])
|
||||
decryptedBytes[len(decryptedBytes)-paddingLength:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,4 +399,3 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"bytes"
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChunk(t *testing.T) {
|
||||
@@ -67,7 +67,6 @@ func TestChunk(t *testing.T) {
|
||||
t.Errorf("Original data:\n%x\nDecrypted data:\n%x\n", plainData, decryptedData)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
@@ -36,7 +37,7 @@ type ChunkDownloader struct {
|
||||
showStatistics bool // Show a stats log for each chunk if true
|
||||
threads int // Number of threads
|
||||
|
||||
taskList [] ChunkDownloadTask // The list of chunks to be downloaded
|
||||
taskList []ChunkDownloadTask // The list of chunks to be downloaded
|
||||
completedTasks map[int]bool // Store downloaded chunks
|
||||
lastChunkIndex int // a monotonically increasing number indicating the last chunk to be downloaded
|
||||
|
||||
@@ -45,15 +46,15 @@ type ChunkDownloader struct {
|
||||
completionChannel chan ChunkDownloadCompletion // A downloading goroutine sends back the chunk via this channel after downloading
|
||||
|
||||
startTime int64 // The time it starts downloading
|
||||
totalFileSize int64 // Total file size
|
||||
downloadedFileSize int64 // Downloaded file size
|
||||
totalChunkSize int64 // Total chunk size
|
||||
downloadedChunkSize int64 // Downloaded chunk size
|
||||
numberOfDownloadedChunks int // The number of chunks that have been downloaded
|
||||
numberOfDownloadingChunks int // The number of chunks still being downloaded
|
||||
numberOfActiveChunks int // The number of chunks that is being downloaded or has been downloaded but not reclaimed
|
||||
}
|
||||
|
||||
func CreateChunkDownloader(config *Config, storage Storage, snapshotCache *FileStorage, showStatistics bool, threads int) *ChunkDownloader {
|
||||
downloader := &ChunkDownloader {
|
||||
downloader := &ChunkDownloader{
|
||||
config: config,
|
||||
storage: storage,
|
||||
snapshotCache: snapshotCache,
|
||||
@@ -77,55 +78,55 @@ func CreateChunkDownloader(config *Config, storage Storage, snapshotCache *FileS
|
||||
defer CatchLogException()
|
||||
for {
|
||||
select {
|
||||
case task := <- downloader.taskQueue:
|
||||
case task := <-downloader.taskQueue:
|
||||
downloader.Download(threadIndex, task)
|
||||
case <- downloader.stopChannel:
|
||||
case <-downloader.stopChannel:
|
||||
return
|
||||
}
|
||||
}
|
||||
} (i)
|
||||
}(i)
|
||||
}
|
||||
|
||||
return downloader
|
||||
}
|
||||
|
||||
// AddFiles adds chunks needed by the specified files to the download list.
|
||||
func (downloader *ChunkDownloader) AddFiles(snapshot *Snapshot, files [] *Entry) {
|
||||
func (downloader *ChunkDownloader) AddFiles(snapshot *Snapshot, files []*Entry) {
|
||||
|
||||
downloader.taskList = nil
|
||||
lastChunkIndex := -1
|
||||
maximumChunks := 0
|
||||
downloader.totalFileSize = 0
|
||||
downloader.totalChunkSize = 0
|
||||
for _, file := range files {
|
||||
if file.Size == 0 {
|
||||
continue
|
||||
}
|
||||
for i := file.StartChunk; i <= file.EndChunk; i++ {
|
||||
if lastChunkIndex != i {
|
||||
task := ChunkDownloadTask {
|
||||
task := ChunkDownloadTask{
|
||||
chunkIndex: len(downloader.taskList),
|
||||
chunkHash: snapshot.ChunkHashes[i],
|
||||
chunkLength: snapshot.ChunkLengths[i],
|
||||
needed: false,
|
||||
}
|
||||
downloader.taskList = append(downloader.taskList, task)
|
||||
downloader.totalChunkSize += int64(snapshot.ChunkLengths[i])
|
||||
} else {
|
||||
downloader.taskList[len(downloader.taskList) - 1].needed = true
|
||||
downloader.taskList[len(downloader.taskList)-1].needed = true
|
||||
}
|
||||
lastChunkIndex = i
|
||||
}
|
||||
file.StartChunk = len(downloader.taskList) - (file.EndChunk - file.StartChunk) - 1
|
||||
file.EndChunk = len(downloader.taskList) - 1
|
||||
if file.EndChunk - file.StartChunk > maximumChunks {
|
||||
if file.EndChunk-file.StartChunk > maximumChunks {
|
||||
maximumChunks = file.EndChunk - file.StartChunk
|
||||
}
|
||||
downloader.totalFileSize += file.Size
|
||||
}
|
||||
}
|
||||
|
||||
// AddChunk adds a single chunk the download list.
|
||||
func (downloader *ChunkDownloader) AddChunk(chunkHash string) int {
|
||||
task := ChunkDownloadTask {
|
||||
task := ChunkDownloadTask{
|
||||
chunkIndex: len(downloader.taskList),
|
||||
chunkHash: chunkHash,
|
||||
chunkLength: 0,
|
||||
@@ -137,7 +138,7 @@ func (downloader *ChunkDownloader) AddChunk(chunkHash string) int {
|
||||
downloader.taskQueue <- task
|
||||
downloader.numberOfDownloadingChunks++
|
||||
downloader.numberOfActiveChunks++
|
||||
downloader.taskList[len(downloader.taskList) - 1].isDownloading = true
|
||||
downloader.taskList[len(downloader.taskList)-1].isDownloading = true
|
||||
}
|
||||
return len(downloader.taskList) - 1
|
||||
}
|
||||
@@ -163,7 +164,7 @@ func (downloader *ChunkDownloader) Prefetch(file *Entry) {
|
||||
downloader.numberOfDownloadingChunks++
|
||||
downloader.numberOfActiveChunks++
|
||||
}
|
||||
} else{
|
||||
} else {
|
||||
LOG_DEBUG("DOWNLOAD_PREFETCH", "%s chunk %s is not needed", file.Path,
|
||||
downloader.config.GetChunkIDFromHash(task.chunkHash))
|
||||
}
|
||||
@@ -173,16 +174,10 @@ func (downloader *ChunkDownloader) Prefetch(file *Entry) {
|
||||
// Reclaim releases the downloaded chunk to the chunk pool
|
||||
func (downloader *ChunkDownloader) Reclaim(chunkIndex int) {
|
||||
|
||||
if downloader.lastChunkIndex == chunkIndex {
|
||||
if downloader.lastChunkIndex >= chunkIndex {
|
||||
return
|
||||
}
|
||||
|
||||
for i := downloader.lastChunkIndex; i < chunkIndex; i++ {
|
||||
if !downloader.taskList[i].isDownloading {
|
||||
atomic.AddInt64(&downloader.downloadedFileSize, int64(downloader.taskList[i].chunkLength))
|
||||
}
|
||||
}
|
||||
|
||||
for i, _ := range downloader.completedTasks {
|
||||
if i < chunkIndex && downloader.taskList[i].chunk != nil {
|
||||
downloader.config.PutChunk(downloader.taskList[i].chunk)
|
||||
@@ -192,13 +187,20 @@ func (downloader *ChunkDownloader) Reclaim(chunkIndex int) {
|
||||
}
|
||||
}
|
||||
|
||||
for i := downloader.lastChunkIndex; i < chunkIndex; i++ {
|
||||
// These chunks are never downloaded if 'isDownloading' is false; note that 'isDownloading' isn't reset to
|
||||
// false after a chunk has been downloaded
|
||||
if !downloader.taskList[i].isDownloading {
|
||||
atomic.AddInt64(&downloader.totalChunkSize, -int64(downloader.taskList[i].chunkLength))
|
||||
}
|
||||
}
|
||||
downloader.lastChunkIndex = chunkIndex
|
||||
}
|
||||
|
||||
// WaitForChunk waits until the specified chunk is ready
|
||||
func (downloader *ChunkDownloader) WaitForChunk(chunkIndex int) (chunk *Chunk) {
|
||||
|
||||
// Reclain any chunk not needed
|
||||
// Reclaim any chunk not needed
|
||||
downloader.Reclaim(chunkIndex)
|
||||
|
||||
// If we haven't started download the specified chunk, download it now
|
||||
@@ -232,7 +234,7 @@ func (downloader *ChunkDownloader) WaitForChunk(chunkIndex int) (chunk *Chunk) {
|
||||
|
||||
// Now wait until the chunk to be downloaded appears in the completed tasks
|
||||
for _, found := downloader.completedTasks[chunkIndex]; !found; _, found = downloader.completedTasks[chunkIndex] {
|
||||
completion := <- downloader.completionChannel
|
||||
completion := <-downloader.completionChannel
|
||||
downloader.completedTasks[completion.chunkIndex] = true
|
||||
downloader.taskList[completion.chunkIndex].chunk = completion.chunk
|
||||
downloader.numberOfDownloadedChunks++
|
||||
@@ -244,7 +246,7 @@ func (downloader *ChunkDownloader) WaitForChunk(chunkIndex int) (chunk *Chunk) {
|
||||
// Stop terminates all downloading goroutines
|
||||
func (downloader *ChunkDownloader) Stop() {
|
||||
for downloader.numberOfDownloadingChunks > 0 {
|
||||
completion := <- downloader.completionChannel
|
||||
completion := <-downloader.completionChannel
|
||||
downloader.completedTasks[completion.chunkIndex] = true
|
||||
downloader.taskList[completion.chunkIndex].chunk = completion.chunk
|
||||
downloader.numberOfDownloadedChunks++
|
||||
@@ -292,7 +294,7 @@ func (downloader *ChunkDownloader) Download(threadIndex int, task ChunkDownloadT
|
||||
} else {
|
||||
LOG_DEBUG("CHUNK_CACHE", "Chunk %s has been loaded from the snapshot cache", chunkID)
|
||||
|
||||
downloader.completionChannel <- ChunkDownloadCompletion{ chunk: chunk, chunkIndex:task.chunkIndex }
|
||||
downloader.completionChannel <- ChunkDownloadCompletion{chunk: chunk, chunkIndex: task.chunkIndex}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -303,6 +305,9 @@ func (downloader *ChunkDownloader) Download(threadIndex int, task ChunkDownloadT
|
||||
// will be set up before the encryption
|
||||
chunk.Reset(false)
|
||||
|
||||
const MaxDownloadAttempts = 3
|
||||
for downloadAttempt := 0; ; downloadAttempt++ {
|
||||
|
||||
// Find the chunk by ID first.
|
||||
chunkPath, exist, _, err := downloader.storage.FindChunk(threadIndex, chunkID, false)
|
||||
if err != nil {
|
||||
@@ -312,69 +317,121 @@ func (downloader *ChunkDownloader) Download(threadIndex int, task ChunkDownloadT
|
||||
|
||||
if !exist {
|
||||
// No chunk is found. Have to find it in the fossil pool again.
|
||||
chunkPath, exist, _, err = downloader.storage.FindChunk(threadIndex, chunkID, true)
|
||||
fossilPath, exist, _, err := downloader.storage.FindChunk(threadIndex, chunkID, true)
|
||||
if err != nil {
|
||||
LOG_ERROR("DOWNLOAD_CHUNK", "Failed to find the chunk %s: %v", chunkID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if !exist {
|
||||
|
||||
retry := false
|
||||
|
||||
// Retry for Hubic or WebDAV as it may return 404 even when the chunk exists
|
||||
if _, ok := downloader.storage.(*HubicStorage); ok {
|
||||
retry = true
|
||||
}
|
||||
|
||||
if _, ok := downloader.storage.(*WebDAVStorage); ok {
|
||||
retry = true
|
||||
}
|
||||
|
||||
if retry && downloadAttempt < MaxDownloadAttempts {
|
||||
LOG_WARN("DOWNLOAD_RETRY", "Failed to find the chunk %s; retrying", chunkID)
|
||||
continue
|
||||
}
|
||||
|
||||
// A chunk is not found. This is a serious error and hopefully it will never happen.
|
||||
if err != nil {
|
||||
LOG_FATAL("DOWNLOAD_CHUNK", "Chunk %s can't be found: %v", chunkID, err)
|
||||
} else {
|
||||
LOG_FATAL("DOWNLOAD_CHUNK", "Chunk %s can't be found", chunkID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
LOG_DEBUG("CHUNK_FOSSIL", "Chunk %s has been marked as a fossil", chunkID)
|
||||
|
||||
// We can't download the fossil directly. We have to turn it back into a regular chunk and try
|
||||
// downloading again.
|
||||
err = downloader.storage.MoveFile(threadIndex, fossilPath, chunkPath)
|
||||
if err != nil {
|
||||
LOG_FATAL("DOWNLOAD_CHUNK", "Failed to resurrect chunk %s: %v", chunkID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
LOG_WARN("DOWNLOAD_RESURRECT", "Fossil %s has been resurrected", chunkID)
|
||||
continue
|
||||
}
|
||||
|
||||
err = downloader.storage.DownloadFile(threadIndex, chunkPath, chunk)
|
||||
if err != nil {
|
||||
LOG_ERROR("UPLOAD_FATAL", "Failed to download the chunk %s: %v", chunkID, err)
|
||||
_, isHubic := downloader.storage.(*HubicStorage)
|
||||
// Retry on EOF or if it is a Hubic backend as it may return 404 even when the chunk exists
|
||||
if (err == io.ErrUnexpectedEOF || isHubic) && downloadAttempt < MaxDownloadAttempts {
|
||||
LOG_WARN("DOWNLOAD_RETRY", "Failed to download the chunk %s: %v; retrying", chunkID, err)
|
||||
chunk.Reset(false)
|
||||
continue
|
||||
} else {
|
||||
LOG_ERROR("DOWNLOAD_CHUNK", "Failed to download the chunk %s: %v", chunkID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = chunk.Decrypt(downloader.config.ChunkKey, task.chunkHash)
|
||||
if err != nil {
|
||||
LOG_ERROR("UPLOAD_CHUNK", "Failed to decrypt the chunk %s: %v", chunkID, err)
|
||||
if downloadAttempt < MaxDownloadAttempts {
|
||||
LOG_WARN("DOWNLOAD_RETRY", "Failed to decrypt the chunk %s: %v; retrying", chunkID, err)
|
||||
chunk.Reset(false)
|
||||
continue
|
||||
} else {
|
||||
LOG_ERROR("DOWNLOAD_DECRYPT", "Failed to decrypt the chunk %s: %v", chunkID, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
actualChunkID := chunk.GetID()
|
||||
if actualChunkID != chunkID {
|
||||
LOG_FATAL("UPLOAD_CORRUPTED", "The chunk %s has a hash id of %s", chunkID, actualChunkID)
|
||||
if downloadAttempt < MaxDownloadAttempts {
|
||||
LOG_WARN("DOWNLOAD_RETRY", "The chunk %s has a hash id of %s; retrying", chunkID, actualChunkID)
|
||||
chunk.Reset(false)
|
||||
continue
|
||||
} else {
|
||||
LOG_FATAL("DOWNLOAD_CORRUPTED", "The chunk %s has a hash id of %s", chunkID, actualChunkID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if len(cachedPath) > 0 {
|
||||
// Save a copy to the local snapshot cache
|
||||
err = downloader.snapshotCache.UploadFile(threadIndex, cachedPath, chunk.GetBytes())
|
||||
err := downloader.snapshotCache.UploadFile(threadIndex, cachedPath, chunk.GetBytes())
|
||||
if err != nil {
|
||||
LOG_WARN("DOWNLOAD_CACHE", "Failed to add the chunk %s to the snapshot cache: %v", chunkID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloader.showStatistics || IsTracing()) && downloader.totalFileSize > 0 {
|
||||
downloadedChunkSize := atomic.AddInt64(&downloader.downloadedChunkSize, int64(chunk.GetLength()))
|
||||
|
||||
atomic.AddInt64(&downloader.downloadedFileSize, int64(chunk.GetLength()))
|
||||
downloadFileSize := atomic.LoadInt64(&downloader.downloadedFileSize)
|
||||
if (downloader.showStatistics || IsTracing()) && downloader.totalChunkSize > 0 {
|
||||
|
||||
now := time.Now().Unix()
|
||||
if now <= downloader.startTime {
|
||||
now = downloader.startTime + 1
|
||||
}
|
||||
speed := downloadFileSize / (now - downloader.startTime)
|
||||
speed := downloadedChunkSize / (now - downloader.startTime)
|
||||
remainingTime := int64(0)
|
||||
if speed > 0 {
|
||||
remainingTime = (downloader.totalFileSize - downloadFileSize) / speed + 1
|
||||
remainingTime = (downloader.totalChunkSize-downloadedChunkSize)/speed + 1
|
||||
}
|
||||
percentage := float32(downloadFileSize * 1000 / downloader.totalFileSize)
|
||||
percentage := float32(downloadedChunkSize * 1000 / downloader.totalChunkSize)
|
||||
LOG_INFO("DOWNLOAD_PROGRESS", "Downloaded chunk %d size %d, %sB/s %s %.1f%%",
|
||||
task.chunkIndex + 1, chunk.GetLength(),
|
||||
PrettySize(speed), PrettyTime(remainingTime), percentage / 10)
|
||||
task.chunkIndex+1, chunk.GetLength(),
|
||||
PrettySize(speed), PrettyTime(remainingTime), percentage/10)
|
||||
} else {
|
||||
LOG_DEBUG("CHUNK_DOWNLOAD", "Chunk %s has been downloaded", chunkID)
|
||||
}
|
||||
|
||||
downloader.completionChannel <- ChunkDownloadCompletion{ chunk: chunk, chunkIndex:task.chunkIndex }
|
||||
downloader.completionChannel <- ChunkDownloadCompletion{chunk: chunk, chunkIndex: task.chunkIndex}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ChunkMaker breaks data into chunks using buzhash. To save memory, the chunk maker only use a circular buffer
|
||||
@@ -35,7 +35,7 @@ type ChunkMaker struct {
|
||||
// buzhash.
|
||||
func CreateChunkMaker(config *Config, hashOnly bool) *ChunkMaker {
|
||||
size := 1
|
||||
for size * 2 <= config.AverageChunkSize {
|
||||
for size*2 <= config.AverageChunkSize {
|
||||
size *= 2
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func CreateChunkMaker(config *Config, hashOnly bool) *ChunkMaker {
|
||||
return nil
|
||||
}
|
||||
|
||||
maker := &ChunkMaker {
|
||||
maker := &ChunkMaker{
|
||||
hashMask: uint64(config.AverageChunkSize - 1),
|
||||
maximumChunkSize: config.MaximumChunkSize,
|
||||
minimumChunkSize: config.MinimumChunkSize,
|
||||
@@ -61,12 +61,12 @@ func CreateChunkMaker(config *Config, hashOnly bool) *ChunkMaker {
|
||||
|
||||
for i := 0; i < 64; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
maker.randomTable[4 * i + j] = binary.LittleEndian.Uint64(randomData[8 * j : 8 * j + 8])
|
||||
maker.randomTable[4*i+j] = binary.LittleEndian.Uint64(randomData[8*j : 8*j+8])
|
||||
}
|
||||
randomData = sha256.Sum256(randomData[:])
|
||||
}
|
||||
|
||||
maker.buffer = make([]byte, 2 * config.MinimumChunkSize)
|
||||
maker.buffer = make([]byte, 2*config.MinimumChunkSize)
|
||||
|
||||
return maker
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func rotateLeftByOne(value uint64) uint64 {
|
||||
return (value << 1) | (value >> 63)
|
||||
}
|
||||
|
||||
func (maker *ChunkMaker) buzhashSum(sum uint64, data [] byte) uint64 {
|
||||
func (maker *ChunkMaker) buzhashSum(sum uint64, data []byte) uint64 {
|
||||
for i := 0; i < len(data); i++ {
|
||||
sum = rotateLeftByOne(sum) ^ maker.randomTable[data[i]]
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (maker *ChunkMaker) buzhashUpdate(sum uint64, out byte, in byte, length int
|
||||
// 'nextReader' returns false, it will process remaining data in the buffer and then quit. When a chunk is identified,
|
||||
// it will call 'endOfChunk' to return the chunk size and a boolean flag indicating if it is the last chunk.
|
||||
func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *Chunk, final bool),
|
||||
nextReader func(size int64, hash string)(io.Reader, bool)) {
|
||||
nextReader func(size int64, hash string) (io.Reader, bool)) {
|
||||
|
||||
maker.bufferStart = 0
|
||||
maker.bufferSize = 0
|
||||
@@ -121,13 +121,13 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
|
||||
// Move data from the buffer to the chunk.
|
||||
fill := func(count int) {
|
||||
if maker.bufferStart + count < maker.bufferCapacity {
|
||||
chunk.Write(maker.buffer[maker.bufferStart : maker.bufferStart + count])
|
||||
if maker.bufferStart+count < maker.bufferCapacity {
|
||||
chunk.Write(maker.buffer[maker.bufferStart : maker.bufferStart+count])
|
||||
maker.bufferStart += count
|
||||
maker.bufferSize -= count
|
||||
} else {
|
||||
chunk.Write(maker.buffer[maker.bufferStart :])
|
||||
chunk.Write(maker.buffer[: count - (maker.bufferCapacity - maker.bufferStart)])
|
||||
chunk.Write(maker.buffer[maker.bufferStart:])
|
||||
chunk.Write(maker.buffer[:count-(maker.bufferCapacity-maker.bufferStart)])
|
||||
maker.bufferStart = count - (maker.bufferCapacity - maker.bufferStart)
|
||||
maker.bufferSize -= count
|
||||
}
|
||||
@@ -146,10 +146,9 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
}
|
||||
|
||||
for {
|
||||
startNewChunk()
|
||||
maker.bufferStart = 0
|
||||
for maker.bufferStart < maker.minimumChunkSize && !isEOF {
|
||||
count, err := reader.Read(maker.buffer[maker.bufferStart : maker.minimumChunkSize])
|
||||
count, err := reader.Read(maker.buffer[maker.bufferStart:maker.minimumChunkSize])
|
||||
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
@@ -174,10 +173,14 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
return
|
||||
} else {
|
||||
endOfChunk(chunk, false)
|
||||
startNewChunk()
|
||||
fileSize = 0
|
||||
fileHasher = maker.config.NewFileHasher()
|
||||
isEOF = false
|
||||
}
|
||||
} else {
|
||||
endOfChunk(chunk, false)
|
||||
startNewChunk()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +197,7 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
count = maker.bufferStart - start
|
||||
}
|
||||
|
||||
count, err = reader.Read(maker.buffer[start : start + count])
|
||||
count, err = reader.Read(maker.buffer[start : start+count])
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
LOG_ERROR("CHUNK_MAKER", "Failed to read %d bytes: %s", count, err.Error())
|
||||
@@ -202,7 +205,7 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
}
|
||||
|
||||
maker.bufferSize += count
|
||||
fileHasher.Write(maker.buffer[start : start + count])
|
||||
fileHasher.Write(maker.buffer[start : start+count])
|
||||
fileSize += int64(count)
|
||||
|
||||
// if EOF is seen, try to switch to next file and continue
|
||||
@@ -226,18 +229,17 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Minimum chunk size has been reached. Calculate the buzhash for the minimum size chunk.
|
||||
if (!minimumReached) {
|
||||
if !minimumReached {
|
||||
|
||||
bytes := maker.minimumChunkSize
|
||||
|
||||
if maker.bufferStart + bytes < maker.bufferCapacity {
|
||||
hashSum = maker.buzhashSum(0, maker.buffer[maker.bufferStart : maker.bufferStart + bytes])
|
||||
if maker.bufferStart+bytes < maker.bufferCapacity {
|
||||
hashSum = maker.buzhashSum(0, maker.buffer[maker.bufferStart:maker.bufferStart+bytes])
|
||||
} else {
|
||||
hashSum = maker.buzhashSum(0, maker.buffer[maker.bufferStart :])
|
||||
hashSum = maker.buzhashSum(0, maker.buffer[maker.bufferStart:])
|
||||
hashSum = maker.buzhashSum(hashSum,
|
||||
maker.buffer[: bytes - (maker.bufferCapacity - maker.bufferStart)])
|
||||
maker.buffer[:bytes-(maker.bufferCapacity-maker.bufferStart)])
|
||||
}
|
||||
|
||||
if (hashSum & maker.hashMask) == 0 {
|
||||
@@ -255,7 +257,7 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
bytes := maker.bufferSize - maker.minimumChunkSize
|
||||
isEOC := false
|
||||
maxSize := maker.maximumChunkSize - chunk.GetLength()
|
||||
for i := 0; i < maker.bufferSize - maker.minimumChunkSize; i++ {
|
||||
for i := 0; i < maker.bufferSize-maker.minimumChunkSize; i++ {
|
||||
out := maker.bufferStart + i
|
||||
if out >= maker.bufferCapacity {
|
||||
out -= maker.bufferCapacity
|
||||
@@ -266,7 +268,7 @@ func (maker *ChunkMaker) ForEachChunk(reader io.Reader, endOfChunk func(chunk *C
|
||||
}
|
||||
|
||||
hashSum = maker.buzhashUpdate(hashSum, maker.buffer[out], maker.buffer[in], maker.minimumChunkSize)
|
||||
if (hashSum & maker.hashMask) == 0 || i == maxSize - maker.minimumChunkSize - 1 {
|
||||
if (hashSum&maker.hashMask) == 0 || i == maxSize-maker.minimumChunkSize-1 {
|
||||
// A chunk is completed.
|
||||
bytes = i + 1 + maker.minimumChunkSize
|
||||
isEOC = true
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"bytes"
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
"io"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func splitIntoChunks(content []byte, n, averageChunkSize, maxChunkSize, minChunkSize,
|
||||
@@ -29,14 +29,14 @@ func splitIntoChunks(content []byte, n, averageChunkSize, maxChunkSize, minChunk
|
||||
|
||||
maker := CreateChunkMaker(config, false)
|
||||
|
||||
var chunks [] string
|
||||
var chunks []string
|
||||
totalChunkSize := 0
|
||||
totalFileSize := int64(0)
|
||||
|
||||
//LOG_INFO("CHUNK_SPLIT", "bufferCapacity: %d", bufferCapacity)
|
||||
|
||||
buffers := make([] *bytes.Buffer, n)
|
||||
sizes := make([] int, n)
|
||||
buffers := make([]*bytes.Buffer, n)
|
||||
sizes := make([]int, n)
|
||||
sizes[0] = 0
|
||||
for i := 1; i < n; i++ {
|
||||
same := true
|
||||
@@ -54,20 +54,20 @@ func splitIntoChunks(content []byte, n, averageChunkSize, maxChunkSize, minChunk
|
||||
|
||||
sort.Sort(sort.IntSlice(sizes))
|
||||
|
||||
for i := 0; i < n - 1; i++ {
|
||||
buffers[i] = bytes.NewBuffer(content[sizes[i] : sizes[i + 1]])
|
||||
for i := 0; i < n-1; i++ {
|
||||
buffers[i] = bytes.NewBuffer(content[sizes[i]:sizes[i+1]])
|
||||
}
|
||||
buffers[n - 1] = bytes.NewBuffer(content[sizes[n - 1]:])
|
||||
buffers[n-1] = bytes.NewBuffer(content[sizes[n-1]:])
|
||||
|
||||
i := 0
|
||||
|
||||
maker.ForEachChunk(buffers[0],
|
||||
func (chunk *Chunk, final bool) {
|
||||
func(chunk *Chunk, final bool) {
|
||||
//LOG_INFO("CHUNK_SPLIT", "i: %d, chunk: %s, size: %d", i, chunk.GetHash(), size)
|
||||
chunks = append(chunks, chunk.GetHash())
|
||||
totalChunkSize += chunk.GetLength()
|
||||
},
|
||||
func (size int64, hash string) (io.Reader, bool) {
|
||||
func(size int64, hash string) (io.Reader, bool) {
|
||||
totalFileSize += size
|
||||
i++
|
||||
if i >= len(buffers) {
|
||||
@@ -76,7 +76,7 @@ func splitIntoChunks(content []byte, n, averageChunkSize, maxChunkSize, minChunk
|
||||
return buffers[i], true
|
||||
})
|
||||
|
||||
if (totalFileSize != int64(totalChunkSize)) {
|
||||
if totalFileSize != int64(totalChunkSize) {
|
||||
LOG_ERROR("CHUNK_SPLIT", "total chunk size: %d, total file size: %d", totalChunkSize, totalFileSize)
|
||||
}
|
||||
return chunks, totalChunkSize
|
||||
@@ -84,9 +84,8 @@ func splitIntoChunks(content []byte, n, averageChunkSize, maxChunkSize, minChunk
|
||||
|
||||
func TestChunkMaker(t *testing.T) {
|
||||
|
||||
|
||||
//sizes := [...] int { 64 }
|
||||
sizes := [...] int { 64, 256, 1024, 1024 * 10 }
|
||||
sizes := [...]int{64, 256, 1024, 1024 * 10}
|
||||
|
||||
for _, size := range sizes {
|
||||
|
||||
@@ -99,15 +98,15 @@ func TestChunkMaker(t *testing.T) {
|
||||
|
||||
chunkArray1, totalSize1 := splitIntoChunks(content, 10, 32, 64, 16, 32)
|
||||
|
||||
capacities := [...]int { 32, 33, 34, 61, 62, 63, 64, 65, 66, 126, 127, 128, 129, 130,
|
||||
capacities := [...]int{32, 33, 34, 61, 62, 63, 64, 65, 66, 126, 127, 128, 129, 130,
|
||||
255, 256, 257, 511, 512, 513, 1023, 1024, 1025,
|
||||
32, 48, 64, 128, 256, 512, 1024, 2048, }
|
||||
32, 48, 64, 128, 256, 512, 1024, 2048}
|
||||
|
||||
//capacities := [...]int { 32 }
|
||||
|
||||
for _, capacity := range capacities {
|
||||
|
||||
for _, n := range [...]int { 6, 7, 8, 9, 10 } {
|
||||
for _, n := range [...]int{6, 7, 8, 9, 10} {
|
||||
chunkArray2, totalSize2 := splitIntoChunks(content, n, 32, 64, 16, capacity)
|
||||
|
||||
if totalSize1 != totalSize2 {
|
||||
|
||||
209
src/duplicacy_chunkoperator.go
Normal file
209
src/duplicacy_chunkoperator.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These are operations that ChunkOperator will perform.
|
||||
const (
|
||||
ChunkOperationFind = 0
|
||||
ChunkOperationDelete = 1
|
||||
ChunkOperationFossilize = 2
|
||||
ChunkOperationResurrect = 3
|
||||
)
|
||||
|
||||
// ChunkOperatorTask is used to pass paramaters for different kinds of chunk operations.
|
||||
type ChunkOperatorTask struct {
|
||||
operation int // The type of operation
|
||||
chunkID string // The chunk id
|
||||
filePath string // The path of the chunk file; it may be empty
|
||||
}
|
||||
|
||||
// ChunkOperator is capable of performing multi-threaded operations on chunks.
|
||||
type ChunkOperator struct {
|
||||
numberOfActiveTasks int64 // The number of chunks that are being operated on
|
||||
storage Storage // This storage
|
||||
threads int // Number of threads
|
||||
taskQueue chan ChunkOperatorTask // Operating goroutines are waiting on this channel for input
|
||||
stopChannel chan bool // Used to stop all the goroutines
|
||||
|
||||
fossils []string // For fossilize operation, the paths of the fossils are stored in this slice
|
||||
fossilsLock *sync.Mutex // The lock for 'fossils'
|
||||
}
|
||||
|
||||
// CreateChunkOperator creates a new ChunkOperator.
|
||||
func CreateChunkOperator(storage Storage, threads int) *ChunkOperator {
|
||||
operator := &ChunkOperator{
|
||||
storage: storage,
|
||||
threads: threads,
|
||||
|
||||
taskQueue: make(chan ChunkOperatorTask, threads*4),
|
||||
stopChannel: make(chan bool),
|
||||
|
||||
fossils: make([]string, 0),
|
||||
fossilsLock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
// Start the operator goroutines
|
||||
for i := 0; i < operator.threads; i++ {
|
||||
go func(threadIndex int) {
|
||||
defer CatchLogException()
|
||||
for {
|
||||
select {
|
||||
case task := <-operator.taskQueue:
|
||||
operator.Run(threadIndex, task)
|
||||
case <-operator.stopChannel:
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
return operator
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Stop() {
|
||||
if atomic.LoadInt64(&operator.numberOfActiveTasks) < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for atomic.LoadInt64(&operator.numberOfActiveTasks) > 0 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
for i := 0; i < operator.threads; i++ {
|
||||
operator.stopChannel <- false
|
||||
}
|
||||
|
||||
// Assign -1 to numberOfActiveTasks so Stop() can be called multiple times
|
||||
atomic.AddInt64(&operator.numberOfActiveTasks, int64(-1))
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) AddTask(operation int, chunkID string, filePath string) {
|
||||
|
||||
task := ChunkOperatorTask{
|
||||
operation: operation,
|
||||
chunkID: chunkID,
|
||||
filePath: filePath,
|
||||
}
|
||||
operator.taskQueue <- task
|
||||
atomic.AddInt64(&operator.numberOfActiveTasks, int64(1))
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Find(chunkID string) {
|
||||
operator.AddTask(ChunkOperationFind, chunkID, "")
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Delete(chunkID string, filePath string) {
|
||||
operator.AddTask(ChunkOperationDelete, chunkID, filePath)
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Fossilize(chunkID string, filePath string) {
|
||||
operator.AddTask(ChunkOperationFossilize, chunkID, filePath)
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Resurrect(chunkID string, filePath string) {
|
||||
operator.AddTask(ChunkOperationResurrect, chunkID, filePath)
|
||||
}
|
||||
|
||||
func (operator *ChunkOperator) Run(threadIndex int, task ChunkOperatorTask) {
|
||||
defer func() {
|
||||
atomic.AddInt64(&operator.numberOfActiveTasks, int64(-1))
|
||||
}()
|
||||
|
||||
// task.filePath may be empty. If so, find the chunk first.
|
||||
if task.operation == ChunkOperationDelete || task.operation == ChunkOperationFossilize {
|
||||
if task.filePath == "" {
|
||||
filePath, exist, _, err := operator.storage.FindChunk(threadIndex, task.chunkID, false)
|
||||
if err != nil {
|
||||
LOG_ERROR("CHUNK_FIND", "Failed to locate the path for the chunk %s: %v", task.chunkID, err)
|
||||
return
|
||||
} else if !exist {
|
||||
if task.operation == ChunkOperationDelete {
|
||||
LOG_WARN("CHUNK_FIND", "Chunk %s does not exist in the storage", task.chunkID)
|
||||
return
|
||||
}
|
||||
|
||||
fossilPath, exist, _, _ := operator.storage.FindChunk(threadIndex, task.chunkID, true)
|
||||
if exist {
|
||||
LOG_WARN("CHUNK_FOSSILIZE", "Chunk %s is already a fossil", task.chunkID)
|
||||
operator.fossilsLock.Lock()
|
||||
operator.fossils = append(operator.fossils, fossilPath)
|
||||
operator.fossilsLock.Unlock()
|
||||
} else {
|
||||
LOG_ERROR("CHUNK_FIND", "Chunk %s does not exist in the storage", task.chunkID)
|
||||
}
|
||||
return
|
||||
}
|
||||
task.filePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
if task.operation == ChunkOperationFind {
|
||||
_, exist, _, err := operator.storage.FindChunk(threadIndex, task.chunkID, false)
|
||||
if err != nil {
|
||||
LOG_ERROR("CHUNK_FIND", "Failed to locate the path for the chunk %s: %v", task.chunkID, err)
|
||||
} else if !exist {
|
||||
LOG_ERROR("CHUNK_FIND", "Chunk %s does not exist in the storage", task.chunkID)
|
||||
} else {
|
||||
LOG_DEBUG("CHUNK_FIND", "Chunk %s exists in the storage", task.chunkID)
|
||||
}
|
||||
} else if task.operation == ChunkOperationDelete {
|
||||
err := operator.storage.DeleteFile(threadIndex, task.filePath)
|
||||
if err != nil {
|
||||
LOG_WARN("CHUNK_DELETE", "Failed to remove the file %s: %v", task.filePath, err)
|
||||
} else {
|
||||
if task.chunkID != "" {
|
||||
LOG_INFO("CHUNK_DELETE", "The chunk %s has been permanently removed", task.chunkID)
|
||||
} else {
|
||||
LOG_INFO("CHUNK_DELETE", "Deleted file %s from the storage", task.filePath)
|
||||
}
|
||||
}
|
||||
} else if task.operation == ChunkOperationFossilize {
|
||||
|
||||
fossilPath := task.filePath + ".fsl"
|
||||
|
||||
err := operator.storage.MoveFile(threadIndex, task.filePath, fossilPath)
|
||||
if err != nil {
|
||||
if _, exist, _, _ := operator.storage.FindChunk(threadIndex, task.chunkID, true); exist {
|
||||
err := operator.storage.DeleteFile(threadIndex, task.filePath)
|
||||
if err == nil {
|
||||
LOG_TRACE("CHUNK_DELETE", "Deleted chunk file %s as the fossil already exists", task.chunkID)
|
||||
}
|
||||
operator.fossilsLock.Lock()
|
||||
operator.fossils = append(operator.fossils, fossilPath)
|
||||
operator.fossilsLock.Unlock()
|
||||
} else {
|
||||
LOG_ERROR("CHUNK_DELETE", "Failed to fossilize the chunk %s: %v", task.chunkID, err)
|
||||
}
|
||||
} else {
|
||||
LOG_TRACE("CHUNK_FOSSILIZE", "The chunk %s has been marked as a fossil", task.chunkID)
|
||||
operator.fossilsLock.Lock()
|
||||
operator.fossils = append(operator.fossils, fossilPath)
|
||||
operator.fossilsLock.Unlock()
|
||||
}
|
||||
} else if task.operation == ChunkOperationResurrect {
|
||||
chunkPath, exist, _, err := operator.storage.FindChunk(threadIndex, task.chunkID, false)
|
||||
if err != nil {
|
||||
LOG_ERROR("CHUNK_FIND", "Failed to locate the path for the chunk %s: %v", task.chunkID, err)
|
||||
}
|
||||
|
||||
if exist {
|
||||
operator.storage.DeleteFile(threadIndex, task.filePath)
|
||||
LOG_INFO("FOSSIL_RESURRECT", "The chunk %s already exists", task.chunkID)
|
||||
} else {
|
||||
err := operator.storage.MoveFile(threadIndex, task.filePath, chunkPath)
|
||||
if err != nil {
|
||||
LOG_ERROR("FOSSIL_RESURRECT", "Failed to resurrect the chunk %s from the fossil %s: %v",
|
||||
task.chunkID, task.filePath, err)
|
||||
} else {
|
||||
LOG_INFO("FOSSIL_RESURRECT", "The chunk %s has been resurrected", task.filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// ChunkUploadTask represents a chunk to be uploaded.
|
||||
type ChunkUploadTask struct {
|
||||
chunk * Chunk
|
||||
chunk *Chunk
|
||||
chunkIndex int
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type ChunkUploader struct {
|
||||
// CreateChunkUploader creates a chunk uploader.
|
||||
func CreateChunkUploader(config *Config, storage Storage, snapshotCache *FileStorage, threads int,
|
||||
completionFunc func(chunk *Chunk, chunkIndex int, skipped bool, chunkSize int, uploadSize int)) *ChunkUploader {
|
||||
uploader := &ChunkUploader {
|
||||
uploader := &ChunkUploader{
|
||||
config: config,
|
||||
storage: storage,
|
||||
snapshotCache: snapshotCache,
|
||||
@@ -56,20 +56,20 @@ func (uploader *ChunkUploader) Start() {
|
||||
defer CatchLogException()
|
||||
for {
|
||||
select {
|
||||
case task := <- uploader.taskQueue:
|
||||
case task := <-uploader.taskQueue:
|
||||
uploader.Upload(threadIndex, task)
|
||||
case <- uploader.stopChannel:
|
||||
case <-uploader.stopChannel:
|
||||
return
|
||||
}
|
||||
}
|
||||
} (i)
|
||||
}(i)
|
||||
}
|
||||
}
|
||||
|
||||
// StartChunk sends a chunk to be uploaded to a waiting uploading goroutine. It may block if all uploading goroutines are busy.
|
||||
func (uploader *ChunkUploader) StartChunk(chunk *Chunk, chunkIndex int) {
|
||||
atomic.AddInt32(&uploader.numberOfUploadingTasks, 1)
|
||||
uploader.taskQueue <- ChunkUploadTask {
|
||||
uploader.taskQueue <- ChunkUploadTask{
|
||||
chunk: chunk,
|
||||
chunkIndex: chunkIndex,
|
||||
}
|
||||
@@ -92,6 +92,11 @@ func (uploader *ChunkUploader) Upload(threadIndex int, task ChunkUploadTask) boo
|
||||
chunkSize := chunk.GetLength()
|
||||
chunkID := chunk.GetID()
|
||||
|
||||
// For a snapshot chunk, verify that its chunk id is correct
|
||||
if uploader.snapshotCache != nil {
|
||||
chunk.VerifyID()
|
||||
}
|
||||
|
||||
if uploader.snapshotCache != nil && uploader.storage.IsCacheNeeded() {
|
||||
// Save a copy to the local snapshot.
|
||||
chunkPath, exist, _, err := uploader.snapshotCache.FindChunk(threadIndex, chunkID, false)
|
||||
@@ -117,7 +122,7 @@ func (uploader *ChunkUploader) Upload(threadIndex int, task ChunkUploadTask) boo
|
||||
// Chunk deduplication by name in effect here.
|
||||
LOG_DEBUG("CHUNK_DUPLICATE", "Chunk %s already exists", chunkID)
|
||||
|
||||
uploader.completionFunc(chunk, task.chunkIndex, false, chunkSize, 0)
|
||||
uploader.completionFunc(chunk, task.chunkIndex, true, chunkSize, 0)
|
||||
atomic.AddInt32(&uploader.numberOfUploadingTasks, -1)
|
||||
return false
|
||||
}
|
||||
@@ -129,13 +134,17 @@ func (uploader *ChunkUploader) Upload(threadIndex int, task ChunkUploadTask) boo
|
||||
return false
|
||||
}
|
||||
|
||||
if !uploader.config.dryRun {
|
||||
err = uploader.storage.UploadFile(threadIndex, chunkPath, chunk.GetBytes())
|
||||
if err != nil {
|
||||
LOG_ERROR("UPLOAD_CHUNK", "Failed to upload the chunk %s: %v", chunkID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
LOG_DEBUG("CHUNK_UPLOAD", "Chunk %s has been uploaded", chunkID)
|
||||
} else {
|
||||
LOG_DEBUG("CHUNK_UPLOAD", "Uploading was skipped for chunk %s", chunkID)
|
||||
}
|
||||
|
||||
uploader.completionFunc(chunk, task.chunkIndex, false, chunkSize, chunk.GetLength())
|
||||
atomic.AddInt32(&uploader.numberOfUploadingTasks, -1)
|
||||
return true
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
"path"
|
||||
"testing"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
@@ -32,7 +32,7 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
} ()
|
||||
}()
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "storage_test")
|
||||
os.RemoveAll(testDir)
|
||||
@@ -48,7 +48,7 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
storage.EnableTestMode()
|
||||
storage.SetRateLimits(testRateLimit, testRateLimit)
|
||||
|
||||
for _, dir := range []string { "chunks", "snapshots" } {
|
||||
for _, dir := range []string{"chunks", "snapshots"} {
|
||||
err = storage.CreateDirectory(0, dir)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create directory %s: %v", dir, err)
|
||||
@@ -56,7 +56,6 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
numberOfChunks := 100
|
||||
maxChunkSize := 64 * 1024
|
||||
|
||||
@@ -68,11 +67,11 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
|
||||
config := CreateConfig()
|
||||
config.MinimumChunkSize = 100
|
||||
config.chunkPool = make(chan *Chunk, numberOfChunks * 2)
|
||||
config.chunkPool = make(chan *Chunk, numberOfChunks*2)
|
||||
totalFileSize := 0
|
||||
|
||||
for i := 0; i < numberOfChunks; i++ {
|
||||
content := make([]byte, rand.Int() % maxChunkSize + 1)
|
||||
content := make([]byte, rand.Int()%maxChunkSize+1)
|
||||
_, err = crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -102,9 +101,8 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
|
||||
chunkUploader.Stop()
|
||||
|
||||
|
||||
chunkDownloader := CreateChunkDownloader(config, storage, nil, true, testThreads)
|
||||
chunkDownloader.totalFileSize = int64(totalFileSize)
|
||||
chunkDownloader.totalChunkSize = int64(totalFileSize)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
chunkDownloader.AddChunk(chunk.GetHash())
|
||||
@@ -113,14 +111,14 @@ func TestUploaderAndDownloader(t *testing.T) {
|
||||
for i, chunk := range chunks {
|
||||
downloaded := chunkDownloader.WaitForChunk(i)
|
||||
if downloaded.GetID() != chunk.GetID() {
|
||||
t.Error("Uploaded: %s, downloaded: %s", chunk.GetID(), downloaded.GetID())
|
||||
t.Errorf("Uploaded: %s, downloaded: %s", chunk.GetID(), downloaded.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
chunkDownloader.Stop()
|
||||
|
||||
for _, file := range listChunks(storage) {
|
||||
err = storage.DeleteFile(0, "chunks/" + file)
|
||||
err = storage.DeleteFile(0, "chunks/"+file)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete the file %s: %v", file, err)
|
||||
return
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"os"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sync/atomic"
|
||||
"crypto/rand"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
blake2 "github.com/minio/blake2b-simd"
|
||||
)
|
||||
@@ -28,6 +29,15 @@ var DEFAULT_KEY = []byte("duplicacy")
|
||||
// standard zlib levels of -1 to 9.
|
||||
var DEFAULT_COMPRESSION_LEVEL = 100
|
||||
|
||||
// The new header of the config file (to differentiate from the old format where the salt and iterations are fixed)
|
||||
var CONFIG_HEADER = "duplicacy\001"
|
||||
|
||||
// The length of the salt used in the new format
|
||||
var CONFIG_SALT_LENGTH = 32
|
||||
|
||||
// The default iterations for key derivation
|
||||
var CONFIG_DEFAULT_ITERATIONS = 16384
|
||||
|
||||
type Config struct {
|
||||
CompressionLevel int `json:"compression-level"`
|
||||
AverageChunkSize int `json:"average-chunk-size"`
|
||||
@@ -36,6 +46,8 @@ type Config struct {
|
||||
|
||||
ChunkSeed []byte `json:"chunk-seed"`
|
||||
|
||||
FixedNesting bool `json:"fixed-nesting"`
|
||||
|
||||
// Use HMAC-SHA256(hashKey, plaintext) as the chunk hash.
|
||||
// Use HMAC-SHA256(idKey, chunk hash) as the file name of the chunk
|
||||
// For chunks, use HMAC-SHA256(chunkKey, chunk hash) as the encryption key
|
||||
@@ -53,11 +65,12 @@ type Config struct {
|
||||
// for encrypting a non-chunk file
|
||||
FileKey []byte `json:"-"`
|
||||
|
||||
chunkPool chan *Chunk `json:"-"`
|
||||
chunkPool chan *Chunk
|
||||
numberOfChunks int32
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// Create an alias to avoid recursive calls on Config.MarshalJSON
|
||||
// Create an alias to avoid recursive calls on Config.MarshalJSON
|
||||
type aliasedConfig Config
|
||||
|
||||
type jsonableConfig struct {
|
||||
@@ -69,9 +82,9 @@ type jsonableConfig struct {
|
||||
FileKey string `json:"file-key"`
|
||||
}
|
||||
|
||||
func (config *Config) MarshalJSON() ([] byte, error) {
|
||||
func (config *Config) MarshalJSON() ([]byte, error) {
|
||||
|
||||
return json.Marshal(&jsonableConfig {
|
||||
return json.Marshal(&jsonableConfig{
|
||||
aliasedConfig: (*aliasedConfig)(config),
|
||||
ChunkSeed: hex.EncodeToString(config.ChunkSeed),
|
||||
HashKey: hex.EncodeToString(config.HashKey),
|
||||
@@ -83,7 +96,7 @@ func (config *Config) MarshalJSON() ([] byte, error) {
|
||||
|
||||
func (config *Config) UnmarshalJSON(description []byte) (err error) {
|
||||
|
||||
aliased := &jsonableConfig {
|
||||
aliased := &jsonableConfig{
|
||||
aliasedConfig: (*aliasedConfig)(config),
|
||||
}
|
||||
|
||||
@@ -130,18 +143,19 @@ func (config *Config) Print() {
|
||||
}
|
||||
|
||||
func CreateConfigFromParameters(compressionLevel int, averageChunkSize int, maximumChunkSize int, mininumChunkSize int,
|
||||
isEncrypted bool, copyFrom *Config) (config *Config) {
|
||||
isEncrypted bool, copyFrom *Config, bitCopy bool) (config *Config) {
|
||||
|
||||
config = &Config {
|
||||
config = &Config{
|
||||
CompressionLevel: compressionLevel,
|
||||
AverageChunkSize: averageChunkSize,
|
||||
MaximumChunkSize: maximumChunkSize,
|
||||
MinimumChunkSize: mininumChunkSize,
|
||||
FixedNesting: true,
|
||||
}
|
||||
|
||||
if isEncrypted {
|
||||
// Randomly generate keys
|
||||
keys := make([]byte, 32 * 5)
|
||||
keys := make([]byte, 32*5)
|
||||
_, err := rand.Read(keys)
|
||||
if err != nil {
|
||||
LOG_ERROR("CONFIG_KEY", "Failed to generate random keys: %v", err)
|
||||
@@ -168,28 +182,34 @@ func CreateConfigFromParameters(compressionLevel int, averageChunkSize int, maxi
|
||||
|
||||
config.ChunkSeed = copyFrom.ChunkSeed
|
||||
config.HashKey = copyFrom.HashKey
|
||||
|
||||
if bitCopy {
|
||||
config.IDKey = copyFrom.IDKey
|
||||
config.ChunkKey = copyFrom.ChunkKey
|
||||
config.FileKey = copyFrom.FileKey
|
||||
}
|
||||
}
|
||||
|
||||
config.chunkPool = make(chan *Chunk, runtime.NumCPU() * 16)
|
||||
config.chunkPool = make(chan *Chunk, runtime.NumCPU()*16)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func CreateConfig() (config *Config) {
|
||||
return &Config {
|
||||
return &Config{
|
||||
HashKey: DEFAULT_KEY,
|
||||
IDKey: DEFAULT_KEY,
|
||||
CompressionLevel: DEFAULT_COMPRESSION_LEVEL,
|
||||
chunkPool: make(chan *Chunk, runtime.NumCPU() * 16),
|
||||
chunkPool: make(chan *Chunk, runtime.NumCPU()*16),
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) GetChunk() (chunk *Chunk) {
|
||||
select {
|
||||
case chunk = <- config.chunkPool :
|
||||
case chunk = <-config.chunkPool:
|
||||
default:
|
||||
numberOfChunks := atomic.AddInt32(&config.numberOfChunks, 1)
|
||||
if numberOfChunks >= int32(runtime.NumCPU() * 16) {
|
||||
if numberOfChunks >= int32(runtime.NumCPU()*16) {
|
||||
LOG_WARN("CONFIG_CHUNK", "%d chunks have been allocated", numberOfChunks)
|
||||
if _, found := os.LookupEnv("DUPLICACY_CHUNK_DEBUG"); found {
|
||||
debug.PrintStack()
|
||||
@@ -200,7 +220,7 @@ func (config *Config) GetChunk() (chunk *Chunk) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
func (config *Config) PutChunk(chunk *Chunk){
|
||||
func (config *Config) PutChunk(chunk *Chunk) {
|
||||
|
||||
if chunk == nil {
|
||||
return
|
||||
@@ -215,7 +235,7 @@ func (config *Config) PutChunk(chunk *Chunk){
|
||||
|
||||
func (config *Config) NewKeyedHasher(key []byte) hash.Hash {
|
||||
if config.CompressionLevel == DEFAULT_COMPRESSION_LEVEL {
|
||||
hasher, err := blake2.New(&blake2.Config{ Size: 32, Key:key })
|
||||
hasher, err := blake2.New(&blake2.Config{Size: 32, Key: key})
|
||||
if err != nil {
|
||||
LOG_ERROR("HASH_KEY", "Invalid hash key: %x", key)
|
||||
}
|
||||
@@ -225,9 +245,42 @@ func (config *Config) NewKeyedHasher(key []byte) hash.Hash {
|
||||
}
|
||||
}
|
||||
|
||||
var SkipFileHash = false
|
||||
|
||||
func init() {
|
||||
if value, found := os.LookupEnv("DUPLICACY_SKIP_FILE_HASH"); found && value != "" && value != "0" {
|
||||
SkipFileHash = true
|
||||
}
|
||||
}
|
||||
|
||||
// Implement a dummy hasher to be used when SkipFileHash is true.
|
||||
type DummyHasher struct {
|
||||
}
|
||||
|
||||
func (hasher *DummyHasher) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (hasher *DummyHasher) Sum(b []byte) []byte {
|
||||
return []byte("")
|
||||
}
|
||||
|
||||
func (hasher *DummyHasher) Reset() {
|
||||
}
|
||||
|
||||
func (hasher *DummyHasher) Size() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (hasher *DummyHasher) BlockSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (config *Config) NewFileHasher() hash.Hash {
|
||||
if config.CompressionLevel == DEFAULT_COMPRESSION_LEVEL {
|
||||
hasher, _ := blake2.New(&blake2.Config{ Size: 32 })
|
||||
if SkipFileHash {
|
||||
return &DummyHasher{}
|
||||
} else if config.CompressionLevel == DEFAULT_COMPRESSION_LEVEL {
|
||||
hasher, _ := blake2.New(&blake2.Config{Size: 32})
|
||||
return hasher
|
||||
} else {
|
||||
return sha256.New()
|
||||
@@ -282,10 +335,45 @@ func DownloadConfig(storage Storage, password string) (config *Config, isEncrypt
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(configFile.GetBytes()) < len(ENCRYPTION_HEADER) {
|
||||
return nil, false, fmt.Errorf("The storage has an invalid config file")
|
||||
}
|
||||
|
||||
if string(configFile.GetBytes()[:len(ENCRYPTION_HEADER)-1]) == ENCRYPTION_HEADER[:len(ENCRYPTION_HEADER)-1] && len(password) == 0 {
|
||||
return nil, true, fmt.Errorf("The storage is likely to have been initialized with a password before")
|
||||
}
|
||||
|
||||
var masterKey []byte
|
||||
|
||||
if len(password) > 0 {
|
||||
masterKey = GenerateKeyFromPassword(password)
|
||||
|
||||
if string(configFile.GetBytes()[:len(ENCRYPTION_HEADER)]) == ENCRYPTION_HEADER {
|
||||
// This is the old config format with a static salt and a fixed number of iterations
|
||||
masterKey = GenerateKeyFromPassword(password, DEFAULT_KEY, CONFIG_DEFAULT_ITERATIONS)
|
||||
LOG_TRACE("CONFIG_FORMAT", "Using a static salt and %d iterations for key derivation", CONFIG_DEFAULT_ITERATIONS)
|
||||
} else if string(configFile.GetBytes()[:len(CONFIG_HEADER)]) == CONFIG_HEADER {
|
||||
// This is the new config format with a random salt and a configurable number of iterations
|
||||
encryptedLength := len(configFile.GetBytes()) - CONFIG_SALT_LENGTH - 4
|
||||
|
||||
// Extract the salt and the number of iterations
|
||||
saltStart := configFile.GetBytes()[len(CONFIG_HEADER):]
|
||||
iterations := binary.LittleEndian.Uint32(saltStart[CONFIG_SALT_LENGTH : CONFIG_SALT_LENGTH+4])
|
||||
LOG_TRACE("CONFIG_ITERATIONS", "Using %d iterations for key derivation", iterations)
|
||||
masterKey = GenerateKeyFromPassword(password, saltStart[:CONFIG_SALT_LENGTH], int(iterations))
|
||||
|
||||
// Copy to a temporary buffer to replace the header and remove the salt and the number of riterations
|
||||
var encrypted bytes.Buffer
|
||||
encrypted.Write([]byte(ENCRYPTION_HEADER))
|
||||
encrypted.Write(saltStart[CONFIG_SALT_LENGTH+4:])
|
||||
|
||||
configFile.Reset(false)
|
||||
configFile.Write(encrypted.Bytes())
|
||||
if len(configFile.GetBytes()) != encryptedLength {
|
||||
LOG_ERROR("CONFIG_DOWNLOAD", "Encrypted config has %d bytes instead of expected %d bytes", len(configFile.GetBytes()), encryptedLength)
|
||||
}
|
||||
} else {
|
||||
return nil, true, fmt.Errorf("The config file has an invalid header")
|
||||
}
|
||||
|
||||
// Decrypt the config file. masterKey == nil means no encryption.
|
||||
err = configFile.Decrypt(masterKey, "")
|
||||
@@ -297,23 +385,21 @@ func DownloadConfig(storage Storage, password string) (config *Config, isEncrypt
|
||||
config = CreateConfig()
|
||||
|
||||
err = json.Unmarshal(configFile.GetBytes(), config)
|
||||
|
||||
if err != nil {
|
||||
if bytes.Equal(configFile.GetBytes()[:9], []byte("duplicacy")) {
|
||||
return nil, true, fmt.Errorf("The storage is likely to have been initialized with a password before")
|
||||
} else {
|
||||
return nil, false, fmt.Errorf("Failed to parse the config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
storage.SetNestingLevels(config)
|
||||
|
||||
return config, false, nil
|
||||
|
||||
}
|
||||
|
||||
func UploadConfig(storage Storage, config *Config, password string) (bool) {
|
||||
func UploadConfig(storage Storage, config *Config, password string, iterations int) bool {
|
||||
|
||||
// This is the key to encrypt the config file.
|
||||
var masterKey []byte
|
||||
salt := make([]byte, CONFIG_SALT_LENGTH)
|
||||
|
||||
if len(password) > 0 {
|
||||
|
||||
@@ -322,7 +408,13 @@ func UploadConfig(storage Storage, config *Config, password string) (bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
masterKey = GenerateKeyFromPassword(password)
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
LOG_ERROR("CONFIG_KEY", "Failed to generate random salt: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
masterKey = GenerateKeyFromPassword(password, salt, iterations)
|
||||
}
|
||||
|
||||
description, err := json.MarshalIndent(config, "", " ")
|
||||
@@ -339,11 +431,26 @@ func UploadConfig(storage Storage, config *Config, password string) (bool) {
|
||||
if len(password) > 0 {
|
||||
// Encrypt the config file with masterKey. If masterKey is nil then no encryption is performed.
|
||||
err = chunk.Encrypt(masterKey, "")
|
||||
|
||||
if err != nil {
|
||||
LOG_ERROR("CONFIG_CREATE", "Failed to create the config file: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// The new encrypted format for config is CONFIG_HEADER + salt + #iterations + encrypted content
|
||||
encryptedLength := len(chunk.GetBytes()) + CONFIG_SALT_LENGTH + 4
|
||||
|
||||
// Copy to a temporary buffer to replace the header and add the salt and the number of iterations
|
||||
var encrypted bytes.Buffer
|
||||
encrypted.Write([]byte(CONFIG_HEADER))
|
||||
encrypted.Write(salt)
|
||||
binary.Write(&encrypted, binary.LittleEndian, uint32(iterations))
|
||||
encrypted.Write(chunk.GetBytes()[len(ENCRYPTION_HEADER):])
|
||||
|
||||
chunk.Reset(false)
|
||||
chunk.Write(encrypted.Bytes())
|
||||
if len(chunk.GetBytes()) != encryptedLength {
|
||||
LOG_ERROR("CONFIG_CREATE", "Encrypted config has %d bytes instead of expected %d bytes", len(chunk.GetBytes()), encryptedLength)
|
||||
}
|
||||
}
|
||||
|
||||
err = storage.UploadFile(0, "config", chunk.GetBytes())
|
||||
@@ -356,7 +463,7 @@ func UploadConfig(storage Storage, config *Config, password string) (bool) {
|
||||
config.Print()
|
||||
}
|
||||
|
||||
for _, subDir := range []string {"chunks", "snapshots"} {
|
||||
for _, subDir := range []string{"chunks", "snapshots"} {
|
||||
err = storage.CreateDirectory(0, subDir)
|
||||
if err != nil {
|
||||
LOG_ERROR("CONFIG_MKDIR", "Failed to create storage subdirectory: %v", err)
|
||||
@@ -369,8 +476,8 @@ func UploadConfig(storage Storage, config *Config, password string) (bool) {
|
||||
// ConfigStorage makes the general storage space available for storing duplicacy format snapshots. In essence,
|
||||
// it simply creates a file named 'config' that stores various parameters as well as a set of keys if encryption
|
||||
// is enabled.
|
||||
func ConfigStorage(storage Storage, compressionLevel int, averageChunkSize int, maximumChunkSize int,
|
||||
minimumChunkSize int, password string, copyFrom *Config) bool {
|
||||
func ConfigStorage(storage Storage, iterations int, compressionLevel int, averageChunkSize int, maximumChunkSize int,
|
||||
minimumChunkSize int, password string, copyFrom *Config, bitCopy bool) bool {
|
||||
|
||||
exist, _, _, err := storage.GetFileInfo(0, "config")
|
||||
if err != nil {
|
||||
@@ -383,12 +490,11 @@ func ConfigStorage(storage Storage, compressionLevel int, averageChunkSize int,
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
config := CreateConfigFromParameters(compressionLevel, averageChunkSize, maximumChunkSize, minimumChunkSize, len(password) > 0,
|
||||
copyFrom)
|
||||
copyFrom, bitCopy)
|
||||
if config == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return UploadConfig(storage, config, password)
|
||||
return UploadConfig(storage, config, password, iterations)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gilbertchen/go-dropbox"
|
||||
)
|
||||
|
||||
type DropboxStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
clients []*dropbox.Files
|
||||
minimumNesting int // The minimum level of directories to dive into before searching for the chunk file.
|
||||
storageDir string
|
||||
}
|
||||
|
||||
// CreateDropboxStorage creates a dropbox storage object.
|
||||
func CreateDropboxStorage(accessToken string, storageDir string, threads int) (storage *DropboxStorage, err error) {
|
||||
func CreateDropboxStorage(accessToken string, storageDir string, minimumNesting int, threads int) (storage *DropboxStorage, err error) {
|
||||
|
||||
var clients []*dropbox.Files
|
||||
for i := 0; i < threads; i++ {
|
||||
@@ -31,13 +32,14 @@ func CreateDropboxStorage(accessToken string, storageDir string, threads int) (s
|
||||
storageDir = "/" + storageDir
|
||||
}
|
||||
|
||||
if len(storageDir) > 1 && storageDir[len(storageDir) - 1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir) - 1]
|
||||
if len(storageDir) > 1 && storageDir[len(storageDir)-1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir)-1]
|
||||
}
|
||||
|
||||
storage = &DropboxStorage {
|
||||
storage = &DropboxStorage{
|
||||
clients: clients,
|
||||
storageDir: storageDir,
|
||||
minimumNesting: minimumNesting,
|
||||
}
|
||||
|
||||
err = storage.CreateDirectory(0, "")
|
||||
@@ -45,6 +47,8 @@ func CreateDropboxStorage(accessToken string, storageDir string, threads int) (s
|
||||
return nil, fmt.Errorf("Can't create storage directory: %v", err)
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{1}, 1)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
@@ -55,13 +59,13 @@ func (storage *DropboxStorage) ListFiles(threadIndex int, dir string) (files []s
|
||||
dir = "/" + dir
|
||||
}
|
||||
|
||||
if len(dir) > 1 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
if len(dir) > 1 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
input := &dropbox.ListFolderInput {
|
||||
Path : storage.storageDir + dir,
|
||||
Recursive : false,
|
||||
input := &dropbox.ListFolderInput{
|
||||
Path: storage.storageDir + dir,
|
||||
Recursive: false,
|
||||
IncludeMediaInfo: false,
|
||||
IncludeDeleted: false,
|
||||
}
|
||||
@@ -85,7 +89,7 @@ func (storage *DropboxStorage) ListFiles(threadIndex int, dir string) (files []s
|
||||
|
||||
if output.HasMore {
|
||||
output, err = storage.clients[threadIndex].ListFolderContinue(
|
||||
&dropbox.ListFolderContinueInput { Cursor: output.Cursor, })
|
||||
&dropbox.ListFolderContinueInput{Cursor: output.Cursor})
|
||||
|
||||
} else {
|
||||
break
|
||||
@@ -102,7 +106,7 @@ func (storage *DropboxStorage) DeleteFile(threadIndex int, filePath string) (err
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
input := &dropbox.DeleteInput {
|
||||
input := &dropbox.DeleteInput{
|
||||
Path: storage.storageDir + filePath,
|
||||
}
|
||||
_, err = storage.clients[threadIndex].Delete(input)
|
||||
@@ -123,7 +127,7 @@ func (storage *DropboxStorage) MoveFile(threadIndex int, from string, to string)
|
||||
if to != "" && to[0] != '/' {
|
||||
to = "/" + to
|
||||
}
|
||||
input := &dropbox.MoveInput {
|
||||
input := &dropbox.MoveInput{
|
||||
FromPath: storage.storageDir + from,
|
||||
ToPath: storage.storageDir + to,
|
||||
}
|
||||
@@ -137,12 +141,12 @@ func (storage *DropboxStorage) CreateDirectory(threadIndex int, dir string) (err
|
||||
dir = "/" + dir
|
||||
}
|
||||
|
||||
if len(dir) > 1 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
if len(dir) > 1 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
input := &dropbox.CreateFolderInput {
|
||||
Path : storage.storageDir + dir,
|
||||
input := &dropbox.CreateFolderInput{
|
||||
Path: storage.storageDir + dir,
|
||||
}
|
||||
|
||||
_, err = storage.clients[threadIndex].CreateFolder(input)
|
||||
@@ -161,7 +165,7 @@ func (storage *DropboxStorage) GetFileInfo(threadIndex int, filePath string) (ex
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
input := &dropbox.GetMetadataInput {
|
||||
input := &dropbox.GetMetadataInput{
|
||||
Path: storage.storageDir + filePath,
|
||||
IncludeMediaInfo: false,
|
||||
}
|
||||
@@ -178,66 +182,6 @@ func (storage *DropboxStorage) GetFileInfo(threadIndex int, filePath string) (ex
|
||||
return true, output.Tag == "folder", int64(output.Size), nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *DropboxStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
dir := "/chunks"
|
||||
|
||||
suffix := ""
|
||||
if isFossil {
|
||||
suffix = ".fsl"
|
||||
}
|
||||
|
||||
// The minimum level of directories to dive into before searching for the chunk file.
|
||||
minimumLevel := 1
|
||||
|
||||
for level := 0; level * 2 < len(chunkID); level ++ {
|
||||
if level >= minimumLevel {
|
||||
filePath = path.Join(dir, chunkID[2 * level:]) + suffix
|
||||
var size int64
|
||||
exist, _, size, err = storage.GetFileInfo(threadIndex, filePath)
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
}
|
||||
if exist {
|
||||
return filePath, exist, size, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Find the subdirectory the chunk file may reside.
|
||||
subDir := path.Join(dir, chunkID[2 * level: 2 * level + 2])
|
||||
exist, _, _, err = storage.GetFileInfo(threadIndex, subDir)
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
}
|
||||
|
||||
if exist {
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
if level < minimumLevel {
|
||||
// Create the subdirectory if it doesn't exist.
|
||||
err = storage.CreateDirectory(threadIndex, subDir)
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
}
|
||||
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
// Teh chunk must be under this subdirectory but it doesn't exist.
|
||||
return path.Join(dir, chunkID[2 * level:])[1:] + suffix, false, 0, nil
|
||||
|
||||
}
|
||||
|
||||
LOG_FATAL("CHUNK_FIND", "Chunk %s is still not found after having searched a maximum level of directories",
|
||||
chunkID)
|
||||
return "", false, 0, nil
|
||||
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *DropboxStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
@@ -245,7 +189,7 @@ func (storage *DropboxStorage) DownloadFile(threadIndex int, filePath string, ch
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
input := &dropbox.DownloadInput {
|
||||
input := &dropbox.DownloadInput{
|
||||
Path: storage.storageDir + filePath,
|
||||
}
|
||||
|
||||
@@ -256,7 +200,7 @@ func (storage *DropboxStorage) DownloadFile(threadIndex int, filePath string, ch
|
||||
|
||||
defer output.Body.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, output.Body, storage.DownloadRateLimit / len(storage.clients))
|
||||
_, err = RateLimitedCopy(chunk, output.Body, storage.DownloadRateLimit/len(storage.clients))
|
||||
return err
|
||||
|
||||
}
|
||||
@@ -267,12 +211,12 @@ func (storage *DropboxStorage) UploadFile(threadIndex int, filePath string, cont
|
||||
filePath = "/" + filePath
|
||||
}
|
||||
|
||||
input := &dropbox.UploadInput {
|
||||
input := &dropbox.UploadInput{
|
||||
Path: storage.storageDir + filePath,
|
||||
Mode: dropbox.WriteModeOverwrite,
|
||||
AutoRename: false,
|
||||
Mute: true,
|
||||
Reader: CreateRateLimitedReader(content, storage.UploadRateLimit / len(storage.clients)),
|
||||
Reader: CreateRateLimitedReader(content, storage.UploadRateLimit/len(storage.clients)),
|
||||
}
|
||||
|
||||
_, err = storage.clients[threadIndex].Upload(input)
|
||||
@@ -281,16 +225,16 @@ func (storage *DropboxStorage) UploadFile(threadIndex int, filePath string, cont
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *DropboxStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *DropboxStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *DropboxStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *DropboxStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *DropboxStorage) IsStrongConsistent() (bool) { return false }
|
||||
func (storage *DropboxStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *DropboxStorage) IsFastListing() (bool) { return false }
|
||||
func (storage *DropboxStorage) IsFastListing() bool { return false }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *DropboxStorage) EnableTestMode() {}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"io/ioutil"
|
||||
"sort"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
// This is the hidden directory in the repository for storing various files.
|
||||
var DUPLICACY_DIRECTORY = ".duplicacy"
|
||||
var DUPLICACY_FILE = ".duplicacy"
|
||||
|
||||
// Mask for file permission bits
|
||||
var fileModeMask = os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky
|
||||
|
||||
// Regex for matching 'StartChunk:StartOffset:EndChunk:EndOffset'
|
||||
var contentRegex = regexp.MustCompile(`^([0-9]+):([0-9]+):([0-9]+):([0-9]+)`)
|
||||
|
||||
@@ -50,18 +51,18 @@ type Entry struct {
|
||||
// CreateEntry creates an entry from file properties.
|
||||
func CreateEntry(path string, size int64, time int64, mode uint32) *Entry {
|
||||
|
||||
if len(path) > 0 && path[len(path) - 1] != '/' && (mode & uint32(os.ModeDir)) != 0 {
|
||||
if len(path) > 0 && path[len(path)-1] != '/' && (mode&uint32(os.ModeDir)) != 0 {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
return &Entry {
|
||||
Path : path,
|
||||
Size : size,
|
||||
Time : time,
|
||||
Mode : mode,
|
||||
return &Entry{
|
||||
Path: path,
|
||||
Size: size,
|
||||
Time: time,
|
||||
Mode: mode,
|
||||
|
||||
UID : -1,
|
||||
GID : -1,
|
||||
UID: -1,
|
||||
GID: -1,
|
||||
}
|
||||
|
||||
}
|
||||
@@ -72,15 +73,15 @@ func CreateEntryFromFileInfo(fileInfo os.FileInfo, directory string) *Entry {
|
||||
|
||||
mode := fileInfo.Mode()
|
||||
|
||||
if mode & os.ModeDir != 0 && mode & os.ModeSymlink != 0 {
|
||||
if mode&os.ModeDir != 0 && mode&os.ModeSymlink != 0 {
|
||||
mode ^= os.ModeDir
|
||||
}
|
||||
|
||||
if path[len(path) - 1] != '/' && mode & os.ModeDir != 0 {
|
||||
if path[len(path)-1] != '/' && mode&os.ModeDir != 0 {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
entry := &Entry {
|
||||
entry := &Entry{
|
||||
Path: path,
|
||||
Size: fileInfo.Size(),
|
||||
Time: fileInfo.ModTime().Unix(),
|
||||
@@ -95,14 +96,14 @@ func CreateEntryFromFileInfo(fileInfo os.FileInfo, directory string) *Entry {
|
||||
// CreateEntryFromJSON creates an entry from a json description.
|
||||
func (entry *Entry) UnmarshalJSON(description []byte) (err error) {
|
||||
|
||||
var object map[string]interface {}
|
||||
var object map[string]interface{}
|
||||
|
||||
err = json.Unmarshal(description, &object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var value interface {}
|
||||
var value interface{}
|
||||
var ok bool
|
||||
|
||||
if value, ok = object["name"]; ok {
|
||||
@@ -171,7 +172,7 @@ func (entry *Entry) UnmarshalJSON(description []byte) (err error) {
|
||||
}
|
||||
|
||||
if value, ok = object["attributes"]; ok {
|
||||
if attributes, ok := value.(map[string]interface {}); !ok {
|
||||
if attributes, ok := value.(map[string]interface{}); !ok {
|
||||
return fmt.Errorf("Attributes are invalid for file '%s' in the snapshot", entry.Path)
|
||||
} else {
|
||||
entry.Attributes = make(map[string][]byte)
|
||||
@@ -251,7 +252,7 @@ func (entry *Entry) convertToObject(encodeName bool) map[string]interface{} {
|
||||
}
|
||||
|
||||
// MarshalJSON returns the json description of an entry.
|
||||
func (entry *Entry) MarshalJSON() ([] byte, error) {
|
||||
func (entry *Entry) MarshalJSON() ([]byte, error) {
|
||||
|
||||
object := entry.convertToObject(true)
|
||||
description, err := json.Marshal(object)
|
||||
@@ -259,28 +260,28 @@ func (entry *Entry) MarshalJSON() ([] byte, error) {
|
||||
}
|
||||
|
||||
func (entry *Entry) IsFile() bool {
|
||||
return entry.Mode & uint32(os.ModeType) == 0
|
||||
return entry.Mode&uint32(os.ModeType) == 0
|
||||
}
|
||||
|
||||
func (entry *Entry) IsDir() bool {
|
||||
return entry.Mode & uint32(os.ModeDir) != 0
|
||||
return entry.Mode&uint32(os.ModeDir) != 0
|
||||
}
|
||||
|
||||
func (entry *Entry) IsLink() bool {
|
||||
return entry.Mode & uint32(os.ModeSymlink) != 0
|
||||
return entry.Mode&uint32(os.ModeSymlink) != 0
|
||||
}
|
||||
|
||||
func (entry *Entry) GetPermissions() os.FileMode {
|
||||
return os.FileMode(entry.Mode) & os.ModePerm
|
||||
return os.FileMode(entry.Mode)&fileModeMask
|
||||
}
|
||||
|
||||
func (entry *Entry) IsSameAs(other *Entry) bool {
|
||||
return entry.Size == other.Size && entry.Time <= other.Time + 1 && entry.Time >= other.Time - 1
|
||||
return entry.Size == other.Size && entry.Time <= other.Time+1 && entry.Time >= other.Time-1
|
||||
}
|
||||
|
||||
func (entry *Entry) IsSameAsFileInfo(other os.FileInfo) bool {
|
||||
time := other.ModTime().Unix()
|
||||
return entry.Size == other.Size() && entry.Time <= time + 1 && entry.Time >= time - 1
|
||||
return entry.Size == other.Size() && entry.Time <= time+1 && entry.Time >= time-1
|
||||
}
|
||||
|
||||
func (entry *Entry) String(maxSizeDigits int) string {
|
||||
@@ -288,7 +289,7 @@ func (entry *Entry) String(maxSizeDigits int) string {
|
||||
return fmt.Sprintf("%*d %s %64s %s", maxSizeDigits, entry.Size, modifiedTime, entry.Hash, entry.Path)
|
||||
}
|
||||
|
||||
func (entry *Entry) RestoreMetadata(fullPath string, fileInfo *os.FileInfo) bool {
|
||||
func (entry *Entry) RestoreMetadata(fullPath string, fileInfo *os.FileInfo, setOwner bool) bool {
|
||||
|
||||
if fileInfo == nil {
|
||||
stat, err := os.Stat(fullPath)
|
||||
@@ -299,7 +300,14 @@ func (entry *Entry) RestoreMetadata(fullPath string, fileInfo *os.FileInfo) bool
|
||||
}
|
||||
}
|
||||
|
||||
if (*fileInfo).Mode() & os.ModePerm != entry.GetPermissions() {
|
||||
// Note that chown can remove setuid/setgid bits so should be called before chmod
|
||||
if setOwner {
|
||||
if !SetOwner(fullPath, entry, fileInfo) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (*fileInfo).Mode()&fileModeMask != entry.GetPermissions() {
|
||||
err := os.Chmod(fullPath, entry.GetPermissions())
|
||||
if err != nil {
|
||||
LOG_ERROR("RESTORE_CHMOD", "Failed to set the file permissions: %v", err)
|
||||
@@ -320,10 +328,9 @@ func (entry *Entry) RestoreMetadata(fullPath string, fileInfo *os.FileInfo) bool
|
||||
entry.SetAttributesToFile(fullPath)
|
||||
}
|
||||
|
||||
return SetOwner(fullPath, entry, fileInfo)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Return -1 if 'left' should appear before 'right', 1 if opposite, and 0 if they are the same.
|
||||
// Files are always arranged before subdirectories under the same parent directory.
|
||||
func (left *Entry) Compare(right *Entry) int {
|
||||
@@ -417,14 +424,14 @@ func (files FileInfoCompare) Less(i, j int) bool {
|
||||
left := files[i]
|
||||
right := files[j]
|
||||
|
||||
if left.IsDir() && left.Mode() & os.ModeSymlink == 0 {
|
||||
if right.IsDir() && right.Mode() & os.ModeSymlink == 0 {
|
||||
if left.IsDir() && left.Mode()&os.ModeSymlink == 0 {
|
||||
if right.IsDir() && right.Mode()&os.ModeSymlink == 0 {
|
||||
return left.Name() < right.Name()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if right.IsDir() && right.Mode() & os.ModeSymlink == 0 {
|
||||
if right.IsDir() && right.Mode()&os.ModeSymlink == 0 {
|
||||
return true
|
||||
} else {
|
||||
return left.Name() < right.Name()
|
||||
@@ -434,8 +441,8 @@ func (files FileInfoCompare) Less(i, j int) bool {
|
||||
|
||||
// ListEntries returns a list of entries representing file and subdirectories under the directory 'path'. Entry paths
|
||||
// are normalized as relative to 'top'. 'patterns' are used to exclude or include certain files.
|
||||
func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string, discardAttributes bool) (directoryList []*Entry,
|
||||
skippedFiles [] string, err error) {
|
||||
func ListEntries(top string, path string, fileList *[]*Entry, patterns []string, nobackupFile string, discardAttributes bool) (directoryList []*Entry,
|
||||
skippedFiles []string, err error) {
|
||||
|
||||
LOG_DEBUG("LIST_ENTRIES", "Listing %s", path)
|
||||
|
||||
@@ -448,13 +455,22 @@ func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string
|
||||
return directoryList, nil, err
|
||||
}
|
||||
|
||||
// This binary search works because ioutil.ReadDir returns files sorted by Name() by default
|
||||
if nobackupFile != "" {
|
||||
ii := sort.Search(len(files), func(ii int) bool { return strings.Compare(files[ii].Name(), nobackupFile) >= 0})
|
||||
if ii < len(files) && files[ii].Name() == nobackupFile {
|
||||
LOG_DEBUG("LIST_NOBACKUP", "%s is excluded due to nobackup file", path)
|
||||
return directoryList, skippedFiles, nil
|
||||
}
|
||||
}
|
||||
|
||||
normalizedPath := path
|
||||
if len(normalizedPath) > 0 && normalizedPath[len(normalizedPath) - 1] != '/' {
|
||||
if len(normalizedPath) > 0 && normalizedPath[len(normalizedPath)-1] != '/' {
|
||||
normalizedPath += "/"
|
||||
}
|
||||
|
||||
normalizedTop := top
|
||||
if normalizedTop != "" && normalizedTop[len(normalizedTop) - 1] != '/' {
|
||||
if normalizedTop != "" && normalizedTop[len(normalizedTop)-1] != '/' {
|
||||
normalizedTop += "/"
|
||||
}
|
||||
|
||||
@@ -468,28 +484,34 @@ func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string
|
||||
}
|
||||
entry := CreateEntryFromFileInfo(f, normalizedPath)
|
||||
if len(patterns) > 0 && !MatchPath(entry.Path, patterns) {
|
||||
LOG_DEBUG("LIST_EXCLUDE", "%s is excluded", entry.Path)
|
||||
continue
|
||||
}
|
||||
if entry.IsLink() {
|
||||
isRegular := false
|
||||
isRegular, entry.Link, err = Readlink(filepath.Join(top, entry.Path))
|
||||
if err != nil {
|
||||
LOG_WARN("LIST_LINK", "Failed to read the symlink %s: %v", entry.Path, err )
|
||||
LOG_WARN("LIST_LINK", "Failed to read the symlink %s: %v", entry.Path, err)
|
||||
skippedFiles = append(skippedFiles, entry.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
if isRegular {
|
||||
entry.Mode ^= uint32(os.ModeSymlink)
|
||||
} else if path == "" && filepath.IsAbs(entry.Link) && !strings.HasPrefix(entry.Link, normalizedTop) {
|
||||
} else if path == "" && (filepath.IsAbs(entry.Link) || filepath.HasPrefix(entry.Link, `\\`)) && !strings.HasPrefix(entry.Link, normalizedTop) {
|
||||
stat, err := os.Stat(filepath.Join(top, entry.Path))
|
||||
if err != nil {
|
||||
LOG_WARN("LIST_LINK", "Failed to read the symlink: %v", err )
|
||||
LOG_WARN("LIST_LINK", "Failed to read the symlink: %v", err)
|
||||
skippedFiles = append(skippedFiles, entry.Path)
|
||||
continue
|
||||
}
|
||||
entry = CreateEntryFromFileInfo(stat, "")
|
||||
|
||||
newEntry := CreateEntryFromFileInfo(stat, "")
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows, stat.Name() is the last component of the target, so we need to construct the correct
|
||||
// path from f.Name(); note that a "/" is append assuming a symbolic link is always a directory
|
||||
newEntry.Path = filepath.Join(normalizedPath, f.Name()) + "/"
|
||||
}
|
||||
entry = newEntry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +519,7 @@ func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string
|
||||
entry.ReadAttributes(top)
|
||||
}
|
||||
|
||||
if f.Mode() & (os.ModeNamedPipe | os.ModeSocket | os.ModeDevice) != 0 {
|
||||
if f.Mode()&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice) != 0 {
|
||||
LOG_WARN("LIST_SKIP", "Skipped non-regular file %s", entry.Path)
|
||||
skippedFiles = append(skippedFiles, entry.Path)
|
||||
continue
|
||||
@@ -519,7 +541,7 @@ func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string
|
||||
}
|
||||
}
|
||||
|
||||
for i, j := 0, len(directoryList) - 1; i < j; i, j = i + 1, j - 1 {
|
||||
for i, j := 0, len(directoryList)-1; i < j; i, j = i+1, j-1 {
|
||||
directoryList[i], directoryList[j] = directoryList[j], directoryList[i]
|
||||
}
|
||||
|
||||
@@ -527,8 +549,8 @@ func ListEntries(top string, path string, fileList *[]*Entry, patterns [] string
|
||||
}
|
||||
|
||||
// Diff returns how many bytes remain unmodifiled between two files.
|
||||
func (entry *Entry) Diff(chunkHashes[]string, chunkLengths[]int,
|
||||
otherHashes[]string, otherLengths [] int) (modifiedLength int64) {
|
||||
func (entry *Entry) Diff(chunkHashes []string, chunkLengths []int,
|
||||
otherHashes []string, otherLengths []int) (modifiedLength int64) {
|
||||
|
||||
var offset1, offset2 int64
|
||||
i1 := entry.StartChunk
|
||||
@@ -552,7 +574,7 @@ func (entry *Entry) Diff(chunkHashes[]string, chunkLengths[]int,
|
||||
offset2 += int64(otherLengths[i2])
|
||||
i2++
|
||||
} else {
|
||||
if chunkHashes[i1] == otherHashes[i2] && end - start == otherLengths[i2] {
|
||||
if chunkHashes[i1] == otherHashes[i2] && end-start == otherLengths[i2] {
|
||||
} else {
|
||||
modifiedLength += int64(chunkLengths[i1])
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEntrySort(t *testing.T) {
|
||||
|
||||
DATA := [...]string {
|
||||
DATA := [...]string{
|
||||
"ab",
|
||||
"ab-",
|
||||
"ab0",
|
||||
@@ -44,15 +44,15 @@ func TestEntrySort(t *testing.T) {
|
||||
var entry1, entry2 *Entry
|
||||
|
||||
for i, p1 := range DATA {
|
||||
if p1[len(p1) - 1] == '/' {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700 | uint32(os.ModeDir))
|
||||
if p1[len(p1)-1] == '/' {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700|uint32(os.ModeDir))
|
||||
} else {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700)
|
||||
}
|
||||
for j, p2 := range DATA {
|
||||
|
||||
if p2[len(p2) - 1] == '/' {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700 | uint32(os.ModeDir))
|
||||
if p2[len(p2)-1] == '/' {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700|uint32(os.ModeDir))
|
||||
} else {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEntryList(t *testing.T) {
|
||||
os.RemoveAll(testDir)
|
||||
os.MkdirAll(testDir, 0700)
|
||||
|
||||
DATA := [...]string {
|
||||
DATA := [...]string{
|
||||
"ab",
|
||||
"ab-",
|
||||
"ab0",
|
||||
@@ -107,19 +107,18 @@ func TestEntryList(t *testing.T) {
|
||||
"ab3/c",
|
||||
}
|
||||
|
||||
|
||||
var entry1, entry2 *Entry
|
||||
|
||||
for i, p1 := range DATA {
|
||||
if p1[len(p1) - 1] == '/' {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700 | uint32(os.ModeDir))
|
||||
if p1[len(p1)-1] == '/' {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700|uint32(os.ModeDir))
|
||||
} else {
|
||||
entry1 = CreateEntry(p1, 0, 0, 0700)
|
||||
}
|
||||
for j, p2 := range DATA {
|
||||
|
||||
if p2[len(p2) - 1] == '/' {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700 | uint32(os.ModeDir))
|
||||
if p2[len(p2)-1] == '/' {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700|uint32(os.ModeDir))
|
||||
} else {
|
||||
entry2 = CreateEntry(p2, 0, 0, 0700)
|
||||
}
|
||||
@@ -151,7 +150,7 @@ func TestEntryList(t *testing.T) {
|
||||
for _, file := range DATA {
|
||||
|
||||
fullPath := filepath.Join(testDir, file)
|
||||
if file[len(file) - 1] == '/' {
|
||||
if file[len(file)-1] == '/' {
|
||||
err := os.Mkdir(fullPath, 0700)
|
||||
if err != nil {
|
||||
t.Errorf("Mkdir(%s) returned an error: %s", fullPath, err)
|
||||
@@ -171,10 +170,10 @@ func TestEntryList(t *testing.T) {
|
||||
entries := make([]*Entry, 0, 4)
|
||||
|
||||
for len(directories) > 0 {
|
||||
directory := directories[len(directories) - 1]
|
||||
directories = directories[:len(directories) - 1]
|
||||
directory := directories[len(directories)-1]
|
||||
directories = directories[:len(directories)-1]
|
||||
entries = append(entries, directory)
|
||||
subdirectories, _, err := ListEntries(testDir, directory.Path, &entries, nil, false)
|
||||
subdirectories, _, err := ListEntries(testDir, directory.Path, &entries, nil, "", false)
|
||||
if err != nil {
|
||||
t.Errorf("ListEntries(%s, %s) returned an error: %s", testDir, directory.Path, err)
|
||||
}
|
||||
@@ -217,4 +216,3 @@ func TestEntryList(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -11,19 +11,19 @@ import (
|
||||
// FileReader wraps a number of files and turns them into a series of readers.
|
||||
type FileReader struct {
|
||||
top string
|
||||
files [] *Entry
|
||||
files []*Entry
|
||||
|
||||
CurrentFile *os.File
|
||||
CurrentIndex int
|
||||
CurrentEntry *Entry
|
||||
|
||||
SkippedFiles [] string
|
||||
SkippedFiles []string
|
||||
}
|
||||
|
||||
// CreateFileReader creates a file reader.
|
||||
func CreateFileReader(top string, files[] *Entry) (*FileReader) {
|
||||
func CreateFileReader(top string, files []*Entry) *FileReader {
|
||||
|
||||
reader := &FileReader {
|
||||
reader := &FileReader{
|
||||
top: top,
|
||||
files: files,
|
||||
CurrentIndex: -1,
|
||||
@@ -35,7 +35,7 @@ func CreateFileReader(top string, files[] *Entry) (*FileReader) {
|
||||
}
|
||||
|
||||
// NextFile switchs to the next file in the file reader.
|
||||
func (reader *FileReader) NextFile() bool{
|
||||
func (reader *FileReader) NextFile() bool {
|
||||
|
||||
if reader.CurrentFile != nil {
|
||||
reader.CurrentFile.Close()
|
||||
@@ -68,7 +68,3 @@ func (reader *FileReader) NextFile() bool{
|
||||
reader.CurrentFile = nil
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,56 +1,65 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"path"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileStorage is a local on-disk file storage implementing the Storage interface.
|
||||
type FileStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
isCacheNeeded bool // Network storages require caching
|
||||
storageDir string
|
||||
numberOfThreads int
|
||||
}
|
||||
|
||||
// CreateFileStorage creates a file storage.
|
||||
func CreateFileStorage(storageDir string, threads int) (storage *FileStorage, err error) {
|
||||
func CreateFileStorage(storageDir string, isCacheNeeded bool, threads int) (storage *FileStorage, err error) {
|
||||
|
||||
var stat os.FileInfo
|
||||
|
||||
stat, err = os.Stat(storageDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(storageDir, 0744)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if !stat.IsDir() {
|
||||
return nil, fmt.Errorf("The storage path %s is a file", storageDir)
|
||||
}
|
||||
}
|
||||
|
||||
for storageDir[len(storageDir) - 1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir) - 1]
|
||||
for storageDir[len(storageDir)-1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir)-1]
|
||||
}
|
||||
|
||||
storage = &FileStorage {
|
||||
storageDir : storageDir,
|
||||
storage = &FileStorage{
|
||||
storageDir: storageDir,
|
||||
isCacheNeeded: isCacheNeeded,
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
// Random number fo generating the temporary chunk file suffix.
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
@@ -69,7 +78,7 @@ func (storage *FileStorage) ListFiles(threadIndex int, dir string) (files []stri
|
||||
|
||||
for _, f := range list {
|
||||
name := f.Name()
|
||||
if f.IsDir() && name[len(name) - 1] != '/' {
|
||||
if f.IsDir() && name[len(name)-1] != '/' {
|
||||
name += "/"
|
||||
}
|
||||
files = append(files, name)
|
||||
@@ -118,68 +127,6 @@ func (storage *FileStorage) GetFileInfo(threadIndex int, filePath string) (exist
|
||||
return true, stat.IsDir(), stat.Size(), nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with the
|
||||
// suffix '.fsl'.
|
||||
func (storage *FileStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
dir := path.Join(storage.storageDir, "chunks")
|
||||
|
||||
suffix := ""
|
||||
if isFossil {
|
||||
suffix = ".fsl"
|
||||
}
|
||||
|
||||
// The minimum level of directories to dive into before searching for the chunk file.
|
||||
minimumLevel := 2
|
||||
|
||||
for level := 0; level * 2 < len(chunkID); level ++ {
|
||||
if level >= minimumLevel {
|
||||
filePath = path.Join(dir, chunkID[2 * level:]) + suffix
|
||||
if stat, err := os.Stat(filePath); err == nil && !stat.IsDir() {
|
||||
return filePath[len(storage.storageDir) + 1:], true, stat.Size(), nil
|
||||
} else if err == nil && stat.IsDir() {
|
||||
return filePath[len(storage.storageDir) + 1:], true, 0, fmt.Errorf("The path %s is a directory", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the subdirectory the chunk file may reside.
|
||||
subDir := path.Join(dir, chunkID[2 * level: 2 * level + 2])
|
||||
stat, err := os.Stat(subDir)
|
||||
if err == nil && stat.IsDir() {
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
if level < minimumLevel {
|
||||
// Create the subdirectory if it doesn't exist.
|
||||
|
||||
if err == nil && !stat.IsDir() {
|
||||
return "", false, 0, fmt.Errorf("The path %s is not a directory", subDir)
|
||||
}
|
||||
|
||||
err = os.Mkdir(subDir, 0744)
|
||||
if err != nil {
|
||||
// The directory may have been created by other threads so check it again.
|
||||
stat, _ := os.Stat(subDir)
|
||||
if stat == nil || !stat.IsDir() {
|
||||
return "", false, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
// The chunk must be under this subdirectory but it doesn't exist.
|
||||
return path.Join(dir, chunkID[2 * level:])[len(storage.storageDir) + 1:] + suffix, false, 0, nil
|
||||
|
||||
}
|
||||
|
||||
LOG_FATAL("CHUNK_FIND", "Chunk %s is still not found after having searched a maximum level of directories",
|
||||
chunkID)
|
||||
return "", false, 0, nil
|
||||
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *FileStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
@@ -190,7 +137,7 @@ func (storage *FileStorage) DownloadFile(threadIndex int, filePath string, chunk
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
if _, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit / storage.numberOfThreads); err != nil {
|
||||
if _, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit/storage.numberOfThreads); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -203,6 +150,26 @@ func (storage *FileStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
fullPath := path.Join(storage.storageDir, filePath)
|
||||
|
||||
if len(strings.Split(filePath, "/")) > 2 {
|
||||
dir := path.Dir(fullPath)
|
||||
// Use Lstat() instead of Stat() since 1) Stat() doesn't work for deduplicated disks on Windows and 2) there isn't
|
||||
// really a need to follow the link if filePath is a link.
|
||||
stat, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(dir, 0744)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if !stat.IsDir() {
|
||||
fmt.Errorf("The path %s is not a directory", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
letters := "abcdefghijklmnopqrstuvwxyz"
|
||||
suffix := make([]byte, 8)
|
||||
for i := range suffix {
|
||||
@@ -211,12 +178,12 @@ func (storage *FileStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
temporaryFile := fullPath + "." + string(suffix) + ".tmp"
|
||||
|
||||
file, err := os.OpenFile(temporaryFile, os.O_WRONLY | os.O_CREATE | os.O_TRUNC, 0644)
|
||||
file, err := os.OpenFile(temporaryFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
_, err = io.Copy(file, reader)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
@@ -241,16 +208,16 @@ func (storage *FileStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *FileStorage) IsCacheNeeded () (bool) { return false }
|
||||
func (storage *FileStorage) IsCacheNeeded() bool { return storage.isCacheNeeded }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *FileStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *FileStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *FileStorage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *FileStorage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *FileStorage) IsFastListing() (bool) { return false }
|
||||
func (storage *FileStorage) IsFastListing() bool { return false }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *FileStorage) EnableTestMode() {}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"path"
|
||||
"time"
|
||||
"sync"
|
||||
"strings"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/context"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -24,17 +24,24 @@ import (
|
||||
"google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
var (
|
||||
GCDFileMimeType = "application/octet-stream"
|
||||
GCDDirectoryMimeType = "application/vnd.google-apps.folder"
|
||||
)
|
||||
|
||||
type GCDStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
service *drive.Service
|
||||
idCache map[string]string
|
||||
idCacheLock *sync.Mutex
|
||||
backoff int
|
||||
idCache map[string]string // only directories are saved in this cache
|
||||
idCacheLock sync.Mutex
|
||||
backoffs []int // desired backoff time in seconds for each thread
|
||||
attempts []int // number of failed attempts since last success for each thread
|
||||
|
||||
createDirectoryLock sync.Mutex
|
||||
isConnected bool
|
||||
numberOfThreads int
|
||||
TestMode bool
|
||||
|
||||
}
|
||||
|
||||
type GCDConfig struct {
|
||||
@@ -44,12 +51,19 @@ type GCDConfig struct {
|
||||
Token oauth2.Token `json:"token"`
|
||||
}
|
||||
|
||||
func (storage *GCDStorage) shouldRetry(err error) (bool, error) {
|
||||
func (storage *GCDStorage) shouldRetry(threadIndex int, err error) (bool, error) {
|
||||
|
||||
const MAX_ATTEMPTS = 15
|
||||
|
||||
maximumBackoff := 64
|
||||
if maximumBackoff < storage.numberOfThreads {
|
||||
maximumBackoff = storage.numberOfThreads
|
||||
}
|
||||
retry := false
|
||||
message := ""
|
||||
if err == nil {
|
||||
storage.backoff = 1
|
||||
storage.backoffs[threadIndex] = 1
|
||||
storage.attempts[threadIndex] = 0
|
||||
return false, nil
|
||||
} else if e, ok := err.(*googleapi.Error); ok {
|
||||
if 500 <= e.Code && e.Code < 600 {
|
||||
@@ -62,8 +76,14 @@ func (storage *GCDStorage) shouldRetry(err error) (bool, error) {
|
||||
retry = true
|
||||
} else if e.Code == 403 {
|
||||
// User Rate Limit Exceeded
|
||||
message = "User Rate Limit Exceeded"
|
||||
message = e.Message
|
||||
retry = true
|
||||
} else if e.Code == 401 {
|
||||
// Only retry on authorization error when storage has been connected before
|
||||
if storage.isConnected {
|
||||
message = "Authorization Error"
|
||||
retry = true
|
||||
}
|
||||
}
|
||||
} else if e, ok := err.(*url.Error); ok {
|
||||
message = e.Error()
|
||||
@@ -77,21 +97,39 @@ func (storage *GCDStorage) shouldRetry(err error) (bool, error) {
|
||||
retry = err.Temporary()
|
||||
}
|
||||
|
||||
if !retry || storage.backoff >= 256{
|
||||
storage.backoff = 1
|
||||
if !retry {
|
||||
return false, err
|
||||
}
|
||||
|
||||
delay := float32(storage.backoff) * rand.Float32()
|
||||
LOG_DEBUG("GCD_RETRY", "%s; retrying after %.2f seconds", message, delay)
|
||||
time.Sleep(time.Duration(float32(storage.backoff) * float32(time.Second)))
|
||||
storage.backoff *= 2
|
||||
if storage.attempts[threadIndex] >= MAX_ATTEMPTS {
|
||||
LOG_INFO("GCD_RETRY", "[%d] Maximum number of retries reached (backoff: %d, attempts: %d)",
|
||||
threadIndex, storage.backoffs[threadIndex], storage.attempts[threadIndex])
|
||||
storage.backoffs[threadIndex] = 1
|
||||
storage.attempts[threadIndex] = 0
|
||||
return false, err
|
||||
}
|
||||
|
||||
if storage.backoffs[threadIndex] < maximumBackoff {
|
||||
storage.backoffs[threadIndex] *= 2
|
||||
}
|
||||
if storage.backoffs[threadIndex] > maximumBackoff {
|
||||
storage.backoffs[threadIndex] = maximumBackoff
|
||||
}
|
||||
storage.attempts[threadIndex] += 1
|
||||
delay := float64(storage.backoffs[threadIndex]) * rand.Float64() * 2
|
||||
LOG_DEBUG("GCD_RETRY", "[%d] %s; retrying after %.2f seconds (backoff: %d, attempts: %d)",
|
||||
threadIndex, message, delay, storage.backoffs[threadIndex], storage.attempts[threadIndex])
|
||||
time.Sleep(time.Duration(delay * float64(time.Second)))
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (storage *GCDStorage) convertFilePath(filePath string) (string) {
|
||||
// convertFilePath converts the path for a fossil in the form of 'chunks/id.fsl' to 'fossils/id'. This is because
|
||||
// GCD doesn't support file renaming. Instead, it only allows one file to be moved from one directory to another.
|
||||
// By adding a layer of path conversion we're pretending that we can rename between 'chunks/id' and 'chunks/id.fsl'
|
||||
func (storage *GCDStorage) convertFilePath(filePath string) string {
|
||||
if strings.HasPrefix(filePath, "chunks/") && strings.HasSuffix(filePath, ".fsl") {
|
||||
return "fossils/" + filePath[len("chunks/"):len(filePath) - len(".fsl")]
|
||||
return "fossils/" + filePath[len("chunks/"):len(filePath)-len(".fsl")]
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
@@ -122,21 +160,21 @@ func (storage *GCDStorage) deletePathID(path string) {
|
||||
storage.idCacheLock.Unlock()
|
||||
}
|
||||
|
||||
func (storage *GCDStorage) listFiles(parentID string, listFiles bool) ([]*drive.File, error) {
|
||||
func (storage *GCDStorage) listFiles(threadIndex int, parentID string, listFiles bool, listDirectories bool) ([]*drive.File, error) {
|
||||
|
||||
if parentID == "" {
|
||||
return nil, fmt.Errorf("No parent ID provided")
|
||||
}
|
||||
|
||||
files := []*drive.File {}
|
||||
files := []*drive.File{}
|
||||
|
||||
startToken := ""
|
||||
|
||||
query := "'" + parentID + "' in parents and "
|
||||
if listFiles {
|
||||
query += "mimeType != 'application/vnd.google-apps.folder'"
|
||||
} else {
|
||||
query += "mimeType = 'application/vnd.google-apps.folder'"
|
||||
query := "'" + parentID + "' in parents and trashed = false "
|
||||
if listFiles && !listDirectories {
|
||||
query += "and mimeType != 'application/vnd.google-apps.folder'"
|
||||
} else if !listFiles && !listDirectories {
|
||||
query += "and mimeType = 'application/vnd.google-apps.folder'"
|
||||
}
|
||||
|
||||
maxCount := int64(1000)
|
||||
@@ -150,7 +188,7 @@ func (storage *GCDStorage) listFiles(parentID string, listFiles bool) ([]*drive.
|
||||
|
||||
for {
|
||||
fileList, err = storage.service.Files.List().Q(query).Fields("nextPageToken", "files(name, mimeType, id, size)").PageToken(startToken).PageSize(maxCount).Do()
|
||||
if retry, e := storage.shouldRetry(err); e == nil && !retry {
|
||||
if retry, e := storage.shouldRetry(threadIndex, err); e == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
continue
|
||||
@@ -167,20 +205,19 @@ func (storage *GCDStorage) listFiles(parentID string, listFiles bool) ([]*drive.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (storage *GCDStorage) listByName(parentID string, name string) (string, bool, int64, error) {
|
||||
func (storage *GCDStorage) listByName(threadIndex int, parentID string, name string) (string, bool, int64, error) {
|
||||
|
||||
var fileList *drive.FileList
|
||||
var err error
|
||||
|
||||
for {
|
||||
query := "name = '" + name + "' and '" + parentID + "' in parents"
|
||||
query := "name = '" + name + "' and '" + parentID + "' in parents and trashed = false "
|
||||
fileList, err = storage.service.Files.List().Q(query).Fields("files(name, mimeType, id, size)").Do()
|
||||
|
||||
if retry, e := storage.shouldRetry(err); e == nil && !retry {
|
||||
if retry, e := storage.shouldRetry(threadIndex, err); e == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
continue
|
||||
@@ -195,10 +232,17 @@ func (storage *GCDStorage) listByName(parentID string, name string) (string, boo
|
||||
|
||||
file := fileList.Files[0]
|
||||
|
||||
return file.Id, file.MimeType == "application/vnd.google-apps.folder", file.Size, nil
|
||||
return file.Id, file.MimeType == GCDDirectoryMimeType, file.Size, nil
|
||||
}
|
||||
|
||||
func (storage *GCDStorage) getIDFromPath(path string) (string, error) {
|
||||
// getIDFromPath returns the id of the given path. If 'createDirectories' is true, create the given path and all its
|
||||
// parent directories if they don't exist. Note that if 'createDirectories' is false, it may return an empty 'fileID'
|
||||
// if the file doesn't exist.
|
||||
func (storage *GCDStorage) getIDFromPath(threadIndex int, filePath string, createDirectories bool) (string, error) {
|
||||
|
||||
if fileID, ok := storage.findPathID(filePath); ok {
|
||||
return fileID, nil
|
||||
}
|
||||
|
||||
fileID := "root"
|
||||
|
||||
@@ -206,33 +250,49 @@ func (storage *GCDStorage) getIDFromPath(path string) (string, error) {
|
||||
fileID = rootID
|
||||
}
|
||||
|
||||
names := strings.Split(path, "/")
|
||||
names := strings.Split(filePath, "/")
|
||||
current := ""
|
||||
for i, name := range names {
|
||||
|
||||
if len(current) == 0 {
|
||||
current = name
|
||||
} else {
|
||||
current = current + "/" + name
|
||||
}
|
||||
|
||||
// Find the intermediate directory in the cache first.
|
||||
current = path.Join(current, name)
|
||||
currentID, ok := storage.findPathID(current)
|
||||
if ok {
|
||||
fileID = currentID
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the directory exists.
|
||||
var err error
|
||||
var isDir bool
|
||||
fileID, isDir, _, err = storage.listByName(fileID, name)
|
||||
fileID, isDir, _, err = storage.listByName(threadIndex, fileID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fileID == "" {
|
||||
return "", fmt.Errorf("Path %s doesn't exist", path)
|
||||
if !createDirectories {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Only one thread can create the directory at a time -- GCD allows multiple directories
|
||||
// to have the same name but different ids.
|
||||
storage.createDirectoryLock.Lock()
|
||||
err = storage.CreateDirectory(threadIndex, current)
|
||||
storage.createDirectoryLock.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to create directory '%s': %v", current, err)
|
||||
}
|
||||
currentID, ok = storage.findPathID(current)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("Directory '%s' created by id not found", current)
|
||||
}
|
||||
fileID = currentID
|
||||
continue
|
||||
} else if isDir {
|
||||
storage.savePathID(current, fileID)
|
||||
}
|
||||
if i != len(names) - 1 && !isDir {
|
||||
return "", fmt.Errorf("Invalid path %s", path)
|
||||
return "", fmt.Errorf("Path '%s' is not a directory", current)
|
||||
}
|
||||
}
|
||||
return fileID, nil
|
||||
@@ -246,40 +306,48 @@ func CreateGCDStorage(tokenFile string, storagePath string, threads int) (storag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcdConfig := &GCDConfig {}
|
||||
gcdConfig := &GCDConfig{}
|
||||
if err := json.Unmarshal(description, gcdConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := oauth2.Config{
|
||||
oauth2Config := oauth2.Config{
|
||||
ClientID: gcdConfig.ClientID,
|
||||
ClientSecret: gcdConfig.ClientSecret,
|
||||
Endpoint: gcdConfig.Endpoint,
|
||||
}
|
||||
|
||||
authClient := config.Client(context.Background(), &gcdConfig.Token)
|
||||
authClient := oauth2Config.Client(context.Background(), &gcdConfig.Token)
|
||||
|
||||
service, err := drive.New(authClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage = &GCDStorage {
|
||||
storage = &GCDStorage{
|
||||
service: service,
|
||||
numberOfThreads: threads,
|
||||
idCache: make(map[string]string),
|
||||
idCacheLock: &sync.Mutex{},
|
||||
backoffs: make([]int, threads),
|
||||
attempts: make([]int, threads),
|
||||
}
|
||||
|
||||
storagePathID, err := storage.getIDFromPath(storagePath)
|
||||
for i := range storage.backoffs {
|
||||
storage.backoffs[i] = 1
|
||||
storage.attempts[i] = 0
|
||||
}
|
||||
|
||||
storagePathID, err := storage.getIDFromPath(0, storagePath, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset the id cache and start with 'storagePathID' as the root
|
||||
storage.idCache = make(map[string]string)
|
||||
storage.idCache[""] = storagePathID
|
||||
|
||||
for _, dir := range []string { "chunks", "snapshots", "fossils" } {
|
||||
dirID, isDir, _, err := storage.listByName(storagePathID, dir)
|
||||
for _, dir := range []string{"chunks", "snapshots", "fossils"} {
|
||||
dirID, isDir, _, err := storage.listByName(0, storagePathID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -289,25 +357,28 @@ func CreateGCDStorage(tokenFile string, storagePath string, threads int) (storag
|
||||
return nil, err
|
||||
}
|
||||
} else if !isDir {
|
||||
return nil, fmt.Errorf("%s/%s is not a directory", storagePath + "/" + dir)
|
||||
return nil, fmt.Errorf("%s/%s is not a directory", storagePath, dir)
|
||||
} else {
|
||||
storage.idCache[dir] = dirID
|
||||
}
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
storage.isConnected = true
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *GCDStorage) ListFiles(threadIndex int, dir string) ([]string, []int64, error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "snapshots" {
|
||||
|
||||
files, err := storage.listFiles(storage.getPathID(dir), false)
|
||||
files, err := storage.listFiles(threadIndex, storage.getPathID(dir), false, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -320,12 +391,15 @@ func (storage *GCDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
}
|
||||
return subDirs, nil, nil
|
||||
} else if strings.HasPrefix(dir, "snapshots/") {
|
||||
pathID, err := storage.getIDFromPath(dir)
|
||||
pathID, err := storage.getIDFromPath(threadIndex, dir, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if pathID == "" {
|
||||
return nil, nil, fmt.Errorf("Path '%s' does not exist", dir)
|
||||
}
|
||||
|
||||
entries, err := storage.listFiles(pathID, true)
|
||||
entries, err := storage.listFiles(threadIndex, pathID, true, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -333,7 +407,6 @@ func (storage *GCDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
files := []string{}
|
||||
|
||||
for _, entry := range entries {
|
||||
storage.savePathID(dir + "/" + entry.Name, entry.Id)
|
||||
files = append(files, entry.Name)
|
||||
}
|
||||
return files, nil, nil
|
||||
@@ -341,20 +414,33 @@ func (storage *GCDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
files := []string{}
|
||||
sizes := []int64{}
|
||||
|
||||
for _, parent := range []string { "chunks", "fossils" } {
|
||||
entries, err := storage.listFiles(storage.getPathID(parent), true)
|
||||
parents := []string{"chunks", "fossils"}
|
||||
for i := 0; i < len(parents); i++ {
|
||||
parent := parents[i]
|
||||
pathID, ok := storage.findPathID(parent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := storage.listFiles(threadIndex, pathID, true, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.MimeType != GCDDirectoryMimeType {
|
||||
name := entry.Name
|
||||
if parent == "fossils" {
|
||||
name += ".fsl"
|
||||
if strings.HasPrefix(parent, "fossils") {
|
||||
name = parent + "/" + name + ".fsl"
|
||||
name = name[len("fossils/"):]
|
||||
} else {
|
||||
name = parent + "/" + name
|
||||
name = name[len("chunks/"):]
|
||||
}
|
||||
storage.savePathID(parent + "/" + entry.Name, entry.Id)
|
||||
files = append(files, name)
|
||||
sizes = append(sizes, entry.Size)
|
||||
} else {
|
||||
parents = append(parents, parent+ "/" + entry.Name)
|
||||
storage.savePathID(parent + "/" + entry.Name, entry.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, sizes, nil
|
||||
@@ -365,18 +451,15 @@ func (storage *GCDStorage) ListFiles(threadIndex int, dir string) ([]string, []i
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *GCDStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if !ok {
|
||||
fileID, err = storage.getIDFromPath(filePath)
|
||||
fileID, err := storage.getIDFromPath(threadIndex, filePath, false)
|
||||
if err != nil {
|
||||
LOG_TRACE("GCD_STORAGE", "Ignored file deletion error: %v", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
err = storage.service.Files.Delete(fileID).Fields("id").Do()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
if retry, err := storage.shouldRetry(threadIndex, err); err == nil && !retry {
|
||||
storage.deletePathID(filePath)
|
||||
return nil
|
||||
} else if retry {
|
||||
@@ -397,21 +480,32 @@ func (storage *GCDStorage) MoveFile(threadIndex int, from string, to string) (er
|
||||
from = storage.convertFilePath(from)
|
||||
to = storage.convertFilePath(to)
|
||||
|
||||
fileID, ok := storage.findPathID(from)
|
||||
if !ok {
|
||||
return fmt.Errorf("Attempting to rename file %s with unknown id", to)
|
||||
fileID, err := storage.getIDFromPath(threadIndex, from, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to retrieve the id of '%s': %v", from, err)
|
||||
}
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("The file '%s' to be moved does not exist", from)
|
||||
}
|
||||
|
||||
fromParentID := storage.getPathID("chunks")
|
||||
toParentID := storage.getPathID("fossils")
|
||||
fromParent := path.Dir(from)
|
||||
fromParentID, err := storage.getIDFromPath(threadIndex, fromParent, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to retrieve the id of the parent directory '%s': %v", fromParent, err)
|
||||
}
|
||||
if fromParentID == "" {
|
||||
return fmt.Errorf("The parent directory '%s' does not exist", fromParent)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(from, "fossils") {
|
||||
fromParentID, toParentID = toParentID, fromParentID
|
||||
toParent := path.Dir(to)
|
||||
toParentID, err := storage.getIDFromPath(threadIndex, toParent, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to retrieve the id of the parent directory '%s': %v", toParent, err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, err = storage.service.Files.Update(fileID, nil).AddParents(toParentID).RemoveParents(fromParentID).Do()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
if retry, err := storage.shouldRetry(threadIndex, err); err == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
continue
|
||||
@@ -420,16 +514,14 @@ func (storage *GCDStorage) MoveFile(threadIndex int, from string, to string) (er
|
||||
}
|
||||
}
|
||||
|
||||
storage.savePathID(to, storage.getPathID(from))
|
||||
storage.deletePathID(from)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
// createDirectory creates a new directory.
|
||||
func (storage *GCDStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
exist, isDir, _, err := storage.GetFileInfo(threadIndex, dir)
|
||||
@@ -444,30 +536,42 @@ func (storage *GCDStorage) CreateDirectory(threadIndex int, dir string) (err err
|
||||
return nil
|
||||
}
|
||||
|
||||
parentID := storage.getPathID("")
|
||||
name := dir
|
||||
|
||||
if strings.HasPrefix(dir, "snapshots/") {
|
||||
parentID = storage.getPathID("snapshots")
|
||||
name = dir[len("snapshots/"):]
|
||||
parentDir := path.Dir(dir)
|
||||
if parentDir == "." {
|
||||
parentDir = ""
|
||||
}
|
||||
|
||||
file := &drive.File {
|
||||
Name: name,
|
||||
MimeType: "application/vnd.google-apps.folder",
|
||||
Parents: []string { parentID },
|
||||
parentID := storage.getPathID(parentDir)
|
||||
if parentID == "" {
|
||||
return fmt.Errorf("Parent directory '%s' does not exist", parentDir)
|
||||
}
|
||||
name := path.Base(dir)
|
||||
|
||||
var file *drive.File
|
||||
|
||||
for {
|
||||
file = &drive.File{
|
||||
Name: name,
|
||||
MimeType: GCDDirectoryMimeType,
|
||||
Parents: []string{parentID},
|
||||
}
|
||||
|
||||
file, err = storage.service.Files.Create(file).Fields("id").Do()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
if retry, err := storage.shouldRetry(threadIndex, err); err == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
} else {
|
||||
|
||||
// Check if the directory has already been created by other thread
|
||||
if _, ok := storage.findPathID(dir); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if retry {
|
||||
continue
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storage.savePathID(dir, file.Id)
|
||||
return nil
|
||||
@@ -475,79 +579,53 @@ func (storage *GCDStorage) CreateDirectory(threadIndex int, dir string) (err err
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *GCDStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
for len(filePath) > 0 && filePath[len(filePath) - 1] == '/' {
|
||||
filePath = filePath[:len(filePath) - 1]
|
||||
for len(filePath) > 0 && filePath[len(filePath)-1] == '/' {
|
||||
filePath = filePath[:len(filePath)-1]
|
||||
}
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if ok {
|
||||
// Only directories are saved in the case so this must be a directory
|
||||
return true, true, 0, nil
|
||||
}
|
||||
|
||||
// GetFileInfo is never called on a fossil
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if !ok {
|
||||
dir := path.Dir(filePath)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
dirID, err := storage.getIDFromPath(dir)
|
||||
dirID, err := storage.getIDFromPath(threadIndex, dir, false)
|
||||
if err != nil {
|
||||
return false, false, 0, err
|
||||
}
|
||||
if dirID == "" {
|
||||
return false, false, 0, nil
|
||||
}
|
||||
|
||||
fileID, isDir, size, err = storage.listByName(dirID, path.Base(filePath))
|
||||
if fileID != "" {
|
||||
fileID, isDir, size, err = storage.listByName(threadIndex, dirID, path.Base(filePath))
|
||||
if fileID != "" && isDir {
|
||||
storage.savePathID(filePath, fileID)
|
||||
}
|
||||
return fileID != "", isDir, size, err
|
||||
}
|
||||
|
||||
for {
|
||||
file, err := storage.service.Files.Get(fileID).Fields("id, mimeType").Do()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
return true, file.MimeType == "application/vnd.google-apps.folder", file.Size, nil
|
||||
} else if retry {
|
||||
continue
|
||||
} else {
|
||||
return false, false, 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *GCDStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
parentID := ""
|
||||
filePath = "chunks/" + chunkID
|
||||
realPath := storage.convertFilePath(filePath)
|
||||
if isFossil {
|
||||
parentID = storage.getPathID("fossils")
|
||||
filePath += ".fsl"
|
||||
} else {
|
||||
parentID = storage.getPathID("chunks")
|
||||
}
|
||||
|
||||
fileID := ""
|
||||
fileID, _, size, err = storage.listByName(parentID, chunkID)
|
||||
if fileID != "" {
|
||||
storage.savePathID(realPath, fileID)
|
||||
}
|
||||
return filePath, fileID != "", size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *GCDStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
// We never download the fossil so there is no need to convert the path
|
||||
fileID, ok := storage.findPathID(filePath)
|
||||
if !ok {
|
||||
fileID, err = storage.getIDFromPath(filePath)
|
||||
fileID, err := storage.getIDFromPath(threadIndex, filePath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage.savePathID(filePath, fileID)
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("%s does not exist", filePath)
|
||||
}
|
||||
|
||||
var response *http.Response
|
||||
|
||||
for {
|
||||
response, err = storage.service.Files.Get(fileID).Download()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
if retry, err := storage.shouldRetry(threadIndex, err); err == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
continue
|
||||
@@ -558,7 +636,7 @@ func (storage *GCDStorage) DownloadFile(threadIndex int, filePath string, chunk
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, response.Body, storage.DownloadRateLimit / storage.numberOfThreads)
|
||||
_, err = RateLimitedCopy(chunk, response.Body, storage.DownloadRateLimit/storage.numberOfThreads)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -572,25 +650,21 @@ func (storage *GCDStorage) UploadFile(threadIndex int, filePath string, content
|
||||
parent = ""
|
||||
}
|
||||
|
||||
parentID, ok := storage.findPathID(parent)
|
||||
if !ok {
|
||||
parentID, err = storage.getIDFromPath(parent)
|
||||
parentID, err := storage.getIDFromPath(threadIndex, parent, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage.savePathID(parent, parentID)
|
||||
}
|
||||
|
||||
file := &drive.File {
|
||||
file := &drive.File{
|
||||
Name: path.Base(filePath),
|
||||
MimeType: "application/octet-stream",
|
||||
Parents: []string { parentID },
|
||||
MimeType: GCDFileMimeType,
|
||||
Parents: []string{parentID},
|
||||
}
|
||||
|
||||
for {
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
_, err = storage.service.Files.Create(file).Media(reader).Fields("id").Do()
|
||||
if retry, err := storage.shouldRetry(err); err == nil && !retry {
|
||||
if retry, err := storage.shouldRetry(threadIndex, err); err == nil && !retry {
|
||||
break
|
||||
} else if retry {
|
||||
continue
|
||||
@@ -604,16 +678,16 @@ func (storage *GCDStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *GCDStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *GCDStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *GCDStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *GCDStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *GCDStorage) IsStrongConsistent() (bool) { return false }
|
||||
func (storage *GCDStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *GCDStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *GCDStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *GCDStorage) EnableTestMode() { storage.TestMode = true }
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
"net/url"
|
||||
"math/rand"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
gcs "cloud.google.com/go/storage"
|
||||
"golang.org/x/net/context"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
gcs "cloud.google.com/go/storage"
|
||||
"google.golang.org/api/googleapi"
|
||||
"google.golang.org/api/iterator"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
type GCSStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
bucket *gcs.BucketHandle
|
||||
storageDir string
|
||||
|
||||
numberOfThreads int
|
||||
TestMode bool
|
||||
|
||||
}
|
||||
|
||||
type GCSConfig struct {
|
||||
@@ -51,7 +50,7 @@ func CreateGCSStorage(tokenFile string, bucketName string, storageDir string, th
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var object map[string]interface {}
|
||||
var object map[string]interface{}
|
||||
|
||||
err = json.Unmarshal(description, &object)
|
||||
if err != nil {
|
||||
@@ -74,7 +73,7 @@ func CreateGCSStorage(tokenFile string, bucketName string, storageDir string, th
|
||||
}
|
||||
tokenSource = config.TokenSource(ctx)
|
||||
} else {
|
||||
gcsConfig := &GCSConfig {}
|
||||
gcsConfig := &GCSConfig{}
|
||||
if err := json.Unmarshal(description, gcsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,18 +91,19 @@ func CreateGCSStorage(tokenFile string, bucketName string, storageDir string, th
|
||||
|
||||
bucket := client.Bucket(bucketName)
|
||||
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir) - 1] != '/' {
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir)-1] != '/' {
|
||||
storageDir += "/"
|
||||
}
|
||||
|
||||
storage = &GCSStorage {
|
||||
storage = &GCSStorage{
|
||||
bucket: bucket,
|
||||
storageDir: storageDir,
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
|
||||
}
|
||||
|
||||
func (storage *GCSStorage) shouldRetry(backoff *int, err error) (bool, error) {
|
||||
@@ -149,14 +149,13 @@ func (storage *GCSStorage) shouldRetry(backoff *int, err error) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *GCSStorage) ListFiles(threadIndex int, dir string) ([]string, []int64, error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
query := gcs.Query {
|
||||
query := gcs.Query{
|
||||
Prefix: storage.storageDir + dir + "/",
|
||||
}
|
||||
dirOnly := false
|
||||
@@ -240,19 +239,6 @@ func (storage *GCSStorage) GetFileInfo(threadIndex int, filePath string) (exist
|
||||
return true, false, attributes.Size, nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *GCSStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
filePath = "chunks/" + chunkID
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
}
|
||||
|
||||
exist, _, size, err = storage.GetFileInfo(threadIndex, filePath)
|
||||
|
||||
return filePath, exist, size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *GCSStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
readCloser, err := storage.bucket.Object(storage.storageDir + filePath).NewReader(context.Background())
|
||||
@@ -260,7 +246,7 @@ func (storage *GCSStorage) DownloadFile(threadIndex int, filePath string, chunk
|
||||
return err
|
||||
}
|
||||
defer readCloser.Close()
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / storage.numberOfThreads)
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.numberOfThreads)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -271,7 +257,7 @@ func (storage *GCSStorage) UploadFile(threadIndex int, filePath string, content
|
||||
for {
|
||||
writeCloser := storage.bucket.Object(storage.storageDir + filePath).NewWriter(context.Background())
|
||||
defer writeCloser.Close()
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
_, err = io.Copy(writeCloser, reader)
|
||||
|
||||
if retry, e := storage.shouldRetry(&backoff, err); e == nil && !retry {
|
||||
@@ -288,16 +274,16 @@ func (storage *GCSStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *GCSStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *GCSStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *GCSStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *GCSStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *GCSStorage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *GCSStorage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *GCSStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *GCSStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *GCSStorage) EnableTestMode() { storage.TestMode = true }
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"sync"
|
||||
"bytes"
|
||||
"strings"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
net_url "net/url"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -64,14 +65,24 @@ func NewHubicClient(tokenFile string) (*HubicClient, error) {
|
||||
}
|
||||
|
||||
client := &HubicClient{
|
||||
HTTPClient: http.DefaultClient,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).Dial,
|
||||
TLSHandshakeTimeout: 60 * time.Second,
|
||||
ResponseHeaderTimeout: 300 * time.Second,
|
||||
ExpectContinueTimeout: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
TokenFile: tokenFile,
|
||||
Token: token,
|
||||
TokenLock: &sync.Mutex{},
|
||||
CredentialLock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err = client.RefreshToken()
|
||||
err = client.RefreshToken(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,7 +100,7 @@ func (client *HubicClient) call(url string, method string, input interface{}, ex
|
||||
var response *http.Response
|
||||
|
||||
backoff := 1
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := 0; i < 11; i++ {
|
||||
|
||||
LOG_DEBUG("HUBIC_CALL", "%s %s", method, url)
|
||||
|
||||
@@ -126,7 +137,7 @@ func (client *HubicClient) call(url string, method string, input interface{}, ex
|
||||
|
||||
if url == HubicCredentialURL {
|
||||
client.TokenLock.Lock()
|
||||
request.Header.Set("Authorization", "Bearer " + client.Token.AccessToken)
|
||||
request.Header.Set("Authorization", "Bearer "+client.Token.AccessToken)
|
||||
client.TokenLock.Unlock()
|
||||
} else if url != HubicRefreshTokenURL {
|
||||
client.CredentialLock.Lock()
|
||||
@@ -140,6 +151,13 @@ func (client *HubicClient) call(url string, method string, input interface{}, ex
|
||||
|
||||
response, err = client.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
if url != HubicCredentialURL {
|
||||
retryAfter := time.Duration((0.5 + rand.Float32()) * 1000.0 * float32(backoff))
|
||||
LOG_INFO("HUBIC_CALL", "%s %s returned an error: %v; retry after %d milliseconds", method, url, err, retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
@@ -161,14 +179,14 @@ func (client *HubicClient) call(url string, method string, input interface{}, ex
|
||||
if response.StatusCode == 401 {
|
||||
|
||||
if url == HubicRefreshTokenURL {
|
||||
return nil, 0, "", HubicError { Status: response.StatusCode, Message: "Authorization error when refreshing token"}
|
||||
return nil, 0, "", HubicError{Status: response.StatusCode, Message: "Authorization error when refreshing token"}
|
||||
}
|
||||
|
||||
if url == HubicCredentialURL {
|
||||
return nil, 0, "", HubicError { Status: response.StatusCode, Message: "Authorization error when retrieving credentials"}
|
||||
return nil, 0, "", HubicError{Status: response.StatusCode, Message: "Authorization error when retrieving credentials"}
|
||||
}
|
||||
|
||||
err = client.RefreshToken()
|
||||
err = client.RefreshToken(true)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -179,24 +197,30 @@ func (client *HubicClient) call(url string, method string, input interface{}, ex
|
||||
}
|
||||
continue
|
||||
} else if response.StatusCode >= 500 && response.StatusCode < 600 {
|
||||
retryAfter := time.Duration(rand.Float32() * 1000.0 * float32(backoff))
|
||||
retryAfter := time.Duration((0.5 + rand.Float32()) * 1000.0 * float32(backoff))
|
||||
LOG_INFO("HUBIC_RETRY", "Response status: %d; retry after %d milliseconds", response.StatusCode, retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
backoff *= 2
|
||||
continue
|
||||
} else if response.StatusCode == 408 {
|
||||
retryAfter := time.Duration((0.5 + rand.Float32()) * 1000.0 * float32(backoff))
|
||||
LOG_INFO("HUBIC_RETRY", "Response status: %d; retry after %d milliseconds", response.StatusCode, retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
backoff *= 2
|
||||
continue
|
||||
} else {
|
||||
return nil, 0, "", HubicError { Status: response.StatusCode, Message: "Hubic API error"}
|
||||
return nil, 0, "", HubicError{Status: response.StatusCode, Message: "Hubic API error"}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, "", fmt.Errorf("Maximum number of retries reached")
|
||||
}
|
||||
|
||||
func (client *HubicClient) RefreshToken() (err error) {
|
||||
func (client *HubicClient) RefreshToken(force bool) (err error) {
|
||||
client.TokenLock.Lock()
|
||||
defer client.TokenLock.Unlock()
|
||||
|
||||
if client.Token.Valid() {
|
||||
if !force && client.Token.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,7 +277,7 @@ type HubicEntry struct {
|
||||
|
||||
func (client *HubicClient) ListEntries(path string) ([]HubicEntry, error) {
|
||||
|
||||
if len(path) > 0 && path[len(path) - 1] != '/' {
|
||||
if len(path) > 0 && path[len(path)-1] != '/' {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
@@ -297,8 +321,8 @@ func (client *HubicClient) ListEntries(path string) ([]HubicEntry, error) {
|
||||
marker = entry.Name
|
||||
} else {
|
||||
marker = entry.Subdir
|
||||
for len(entry.Subdir) > 0 && entry.Subdir[len(entry.Subdir) - 1] == '/' {
|
||||
entry.Subdir = entry.Subdir[:len(entry.Subdir) - 1]
|
||||
for len(entry.Subdir) > 0 && entry.Subdir[len(entry.Subdir)-1] == '/' {
|
||||
entry.Subdir = entry.Subdir[:len(entry.Subdir)-1]
|
||||
}
|
||||
entry.Name = entry.Subdir
|
||||
entry.Type = "application/directory"
|
||||
@@ -318,8 +342,8 @@ func (client *HubicClient) ListEntries(path string) ([]HubicEntry, error) {
|
||||
|
||||
func (client *HubicClient) GetFileInfo(path string) (bool, bool, int64, error) {
|
||||
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
for len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
@@ -342,8 +366,8 @@ func (client *HubicClient) GetFileInfo(path string) (bool, bool, int64, error) {
|
||||
|
||||
func (client *HubicClient) DownloadFile(path string) (io.ReadCloser, int64, error) {
|
||||
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
for len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
@@ -356,8 +380,8 @@ func (client *HubicClient) DownloadFile(path string) (io.ReadCloser, int64, erro
|
||||
|
||||
func (client *HubicClient) UploadFile(path string, content []byte, rateLimit int) (err error) {
|
||||
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
for len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
@@ -379,8 +403,8 @@ func (client *HubicClient) UploadFile(path string, content []byte, rateLimit int
|
||||
|
||||
func (client *HubicClient) DeleteFile(path string) error {
|
||||
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
for len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
@@ -399,12 +423,12 @@ func (client *HubicClient) DeleteFile(path string) error {
|
||||
|
||||
func (client *HubicClient) MoveFile(from string, to string) error {
|
||||
|
||||
for len(from) > 0 && from[len(from) - 1] == '/' {
|
||||
from = from[:len(from) - 1]
|
||||
for len(from) > 0 && from[len(from)-1] == '/' {
|
||||
from = from[:len(from)-1]
|
||||
}
|
||||
|
||||
for len(to) > 0 && to[len(to) - 1] == '/' {
|
||||
to = to[:len(to) - 1]
|
||||
for len(to) > 0 && to[len(to)-1] == '/' {
|
||||
to = to[:len(to)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
@@ -425,10 +449,10 @@ func (client *HubicClient) MoveFile(from string, to string) error {
|
||||
return client.DeleteFile(from)
|
||||
}
|
||||
|
||||
func (client *HubicClient) CreateDirectory(path string) (error) {
|
||||
func (client *HubicClient) CreateDirectory(path string) error {
|
||||
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
for len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
client.CredentialLock.Lock()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"fmt"
|
||||
"testing"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
@@ -73,7 +73,7 @@ func TestHubicClient(t *testing.T) {
|
||||
maxFileSize := 64 * 1024
|
||||
|
||||
for i := 0; i < numberOfFiles; i++ {
|
||||
content := make([]byte, rand.Int() % maxFileSize + 1)
|
||||
content := make([]byte, rand.Int()%maxFileSize+1)
|
||||
_, err = crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -86,7 +86,7 @@ func TestHubicClient(t *testing.T) {
|
||||
|
||||
fmt.Printf("file: %s\n", filename)
|
||||
|
||||
err = hubicClient.UploadFile("test/test1/" + filename, content, 100)
|
||||
err = hubicClient.UploadFile("test/test1/"+filename, content, 100)
|
||||
if err != nil {
|
||||
/*if e, ok := err.(ACDError); !ok || e.Status != 409 */ {
|
||||
t.Errorf("Failed to upload the file %s: %v", filename, err)
|
||||
@@ -104,9 +104,9 @@ func TestHubicClient(t *testing.T) {
|
||||
for _, entry := range entries {
|
||||
|
||||
exists, isDir, size, err := hubicClient.GetFileInfo("test/test1/" + entry.Name)
|
||||
fmt.Printf("%s exists: %t, isDir: %t, size: %d, err: %v\n", "test/test1/" + entry.Name, exists, isDir, size, err)
|
||||
fmt.Printf("%s exists: %t, isDir: %t, size: %d, err: %v\n", "test/test1/"+entry.Name, exists, isDir, size, err)
|
||||
|
||||
err = hubicClient.MoveFile("test/test1/" + entry.Name, "test/test2/" + entry.Name)
|
||||
err = hubicClient.MoveFile("test/test1/"+entry.Name, "test/test2/"+entry.Name)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to move %s: %v", entry.Name, err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
type HubicStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
client *HubicClient
|
||||
storageDir string
|
||||
@@ -20,8 +20,8 @@ type HubicStorage struct {
|
||||
// CreateHubicStorage creates an Hubic storage object.
|
||||
func CreateHubicStorage(tokenFile string, storagePath string, threads int) (storage *HubicStorage, err error) {
|
||||
|
||||
for len(storagePath) > 0 && storagePath[len(storagePath) - 1] == '/' {
|
||||
storagePath = storagePath[:len(storagePath) - 1]
|
||||
for len(storagePath) > 0 && storagePath[len(storagePath)-1] == '/' {
|
||||
storagePath = storagePath[:len(storagePath)-1]
|
||||
}
|
||||
|
||||
client, err := NewHubicClient(tokenFile)
|
||||
@@ -42,13 +42,13 @@ func CreateHubicStorage(tokenFile string, storagePath string, threads int) (stor
|
||||
return nil, fmt.Errorf("Path '%s' is not a directory", storagePath)
|
||||
}
|
||||
|
||||
storage = &HubicStorage {
|
||||
storage = &HubicStorage{
|
||||
client: client,
|
||||
storageDir: storagePath,
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
for _, path := range []string { "chunks", "snapshots" } {
|
||||
for _, path := range []string{"chunks", "snapshots"} {
|
||||
dir := storagePath + "/" + path
|
||||
exists, isDir, _, err := client.GetFileInfo(dir)
|
||||
if err != nil {
|
||||
@@ -64,14 +64,15 @@ func CreateHubicStorage(tokenFile string, storagePath string, threads int) (stor
|
||||
}
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *HubicStorage) ListFiles(threadIndex int, dir string) ([]string, []int64, error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "snapshots" {
|
||||
@@ -83,7 +84,7 @@ func (storage *HubicStorage) ListFiles(threadIndex int, dir string) ([]string, [
|
||||
subDirs := []string{}
|
||||
for _, entry := range entries {
|
||||
if entry.Type == "application/directory" {
|
||||
subDirs = append(subDirs, entry.Name + "/")
|
||||
subDirs = append(subDirs, entry.Name+"/")
|
||||
}
|
||||
}
|
||||
return subDirs, nil, nil
|
||||
@@ -105,18 +106,20 @@ func (storage *HubicStorage) ListFiles(threadIndex int, dir string) ([]string, [
|
||||
} else {
|
||||
files := []string{}
|
||||
sizes := []int64{}
|
||||
entries, err := storage.client.ListEntries(storage.storageDir + "/chunks")
|
||||
entries, err := storage.client.ListEntries(storage.storageDir + "/" + dir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Type == "application/directory" {
|
||||
continue
|
||||
}
|
||||
files = append(files, entry.Name + "/")
|
||||
sizes = append(sizes, 0)
|
||||
} else {
|
||||
files = append(files, entry.Name)
|
||||
sizes = append(sizes, entry.Size)
|
||||
}
|
||||
}
|
||||
return files, sizes, nil
|
||||
}
|
||||
|
||||
@@ -142,8 +145,8 @@ func (storage *HubicStorage) MoveFile(threadIndex int, from string, to string) (
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *HubicStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
return storage.client.CreateDirectory(storage.storageDir + "/" + dir)
|
||||
@@ -152,24 +155,12 @@ func (storage *HubicStorage) CreateDirectory(threadIndex int, dir string) (err e
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *HubicStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
|
||||
for len(filePath) > 0 && filePath[len(filePath) - 1] == '/' {
|
||||
filePath = filePath[:len(filePath) - 1]
|
||||
for len(filePath) > 0 && filePath[len(filePath)-1] == '/' {
|
||||
filePath = filePath[:len(filePath)-1]
|
||||
}
|
||||
return storage.client.GetFileInfo(storage.storageDir + "/" + filePath)
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *HubicStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
filePath = "chunks/" + chunkID
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
}
|
||||
|
||||
exist, _, size, err = storage.client.GetFileInfo(storage.storageDir + "/" + filePath)
|
||||
return filePath, exist, size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *HubicStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
readCloser, _, err := storage.client.DownloadFile(storage.storageDir + "/" + filePath)
|
||||
@@ -179,27 +170,27 @@ func (storage *HubicStorage) DownloadFile(threadIndex int, filePath string, chun
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / storage.numberOfThreads)
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.numberOfThreads)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *HubicStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
return storage.client.UploadFile(storage.storageDir + "/" + filePath, content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
return storage.client.UploadFile(storage.storageDir+"/"+filePath, content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *HubicStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *HubicStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *HubicStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *HubicStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *HubicStorage) IsStrongConsistent() (bool) { return false }
|
||||
func (storage *HubicStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *HubicStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *HubicStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *HubicStorage) EnableTestMode() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
// +build !windows
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
var keyringFile string
|
||||
@@ -33,11 +33,11 @@ func SetKeyringFile(path string) {
|
||||
|
||||
func keyringEncrypt(value []byte) ([]byte, error) {
|
||||
|
||||
dataIn := DATA_BLOB {
|
||||
dataIn := DATA_BLOB{
|
||||
pbData: &value[0],
|
||||
cbData: uint32(len(value)),
|
||||
}
|
||||
dataOut := DATA_BLOB {}
|
||||
dataOut := DATA_BLOB{}
|
||||
|
||||
r, _, err := procEncryptData.Call(uintptr(unsafe.Pointer(&dataIn)),
|
||||
0, 0, 0, 0, 0, uintptr(unsafe.Pointer(&dataOut)))
|
||||
@@ -57,11 +57,11 @@ func keyringEncrypt(value []byte) ([]byte, error) {
|
||||
|
||||
func keyringDecrypt(value []byte) ([]byte, error) {
|
||||
|
||||
dataIn := DATA_BLOB {
|
||||
dataIn := DATA_BLOB{
|
||||
pbData: &value[0],
|
||||
cbData: uint32(len(value)),
|
||||
}
|
||||
dataOut := DATA_BLOB {}
|
||||
dataOut := DATA_BLOB{}
|
||||
|
||||
r, _, err := procDecryptData.Call(uintptr(unsafe.Pointer(&dataIn)),
|
||||
0, 0, 0, 0, 0, uintptr(unsafe.Pointer(&dataOut)))
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"time"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"testing"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
ASSERT = 4
|
||||
)
|
||||
|
||||
var LogFunction func(level int, logID string, message string)
|
||||
|
||||
var printLogHeader = false
|
||||
|
||||
@@ -117,13 +118,18 @@ func logf(level int, logID string, format string, v ...interface{}) {
|
||||
|
||||
message := fmt.Sprintf(format, v...)
|
||||
|
||||
if LogFunction != nil {
|
||||
LogFunction(level, logID, message)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Uncomment this line to enable unbufferred logging for tests
|
||||
// fmt.Printf("%s %s %s %s\n", now.Format("2006-01-02 15:04:05.000"), getLevelName(level), logID, message)
|
||||
|
||||
if testingT != nil {
|
||||
if level < WARN {
|
||||
if level <= WARN {
|
||||
if level >= loggingLevel {
|
||||
testingT.Logf("%s %s %s %s\n",
|
||||
now.Format("2006-01-02 15:04:05.000"), getLevelName(level), logID, message)
|
||||
@@ -160,6 +166,9 @@ const (
|
||||
otherExitCode = 101
|
||||
)
|
||||
|
||||
// This is the function to be called before exiting when an error occurs.
|
||||
var RunAtError func() = func() {}
|
||||
|
||||
func CatchLogException() {
|
||||
if r := recover(); r != nil {
|
||||
switch e := r.(type) {
|
||||
@@ -167,10 +176,12 @@ func CatchLogException() {
|
||||
if printStackTrace {
|
||||
debug.PrintStack()
|
||||
}
|
||||
RunAtError()
|
||||
os.Exit(duplicacyExitCode)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "%v\n", e)
|
||||
debug.PrintStack()
|
||||
RunAtError()
|
||||
os.Exit(otherExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"sync"
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -41,6 +42,7 @@ type OneDriveClient struct {
|
||||
Token *oauth2.Token
|
||||
TokenLock *sync.Mutex
|
||||
|
||||
IsConnected bool
|
||||
TestMode bool
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ func NewOneDriveClient(tokenFile string) (*OneDriveClient, error) {
|
||||
TokenLock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
client.RefreshToken(false)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ func (client *OneDriveClient) call(url string, method string, input interface{},
|
||||
case []byte:
|
||||
inputReader = bytes.NewReader(input.([]byte))
|
||||
case int:
|
||||
inputReader = bytes.NewReader([]byte(""))
|
||||
inputReader = nil
|
||||
case *bytes.Buffer:
|
||||
inputReader = bytes.NewReader(input.(*bytes.Buffer).Bytes())
|
||||
case *RateLimitedReader:
|
||||
@@ -106,7 +110,7 @@ func (client *OneDriveClient) call(url string, method string, input interface{},
|
||||
|
||||
if url != OneDriveRefreshTokenURL {
|
||||
client.TokenLock.Lock()
|
||||
request.Header.Set("Authorization", "Bearer " + client.Token.AccessToken)
|
||||
request.Header.Set("Authorization", "Bearer "+client.Token.AccessToken)
|
||||
client.TokenLock.Unlock()
|
||||
}
|
||||
if contentType != "" {
|
||||
@@ -115,43 +119,60 @@ func (client *OneDriveClient) call(url string, method string, input interface{},
|
||||
|
||||
response, err = client.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
if client.IsConnected {
|
||||
if strings.Contains(err.Error(), "TLS handshake timeout") {
|
||||
// Give a long timeout regardless of backoff when a TLS timeout happens, hoping that
|
||||
// idle connections are not to be reused on reconnect.
|
||||
retryAfter := time.Duration(rand.Float32()*60000 + 180000)
|
||||
LOG_INFO("ONEDRIVE_RETRY", "TLS handshake timeout; retry after %d milliseconds", retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
} else {
|
||||
// For all other errors just blindly retry until the maximum is reached
|
||||
retryAfter := time.Duration(rand.Float32() * 1000.0 * float32(backoff))
|
||||
LOG_INFO("ONEDRIVE_RETRY", "%v; retry after %d milliseconds", err, retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
}
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
client.IsConnected = true
|
||||
|
||||
if response.StatusCode < 400 {
|
||||
return response.Body, response.ContentLength, nil
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
errorResponse := &OneDriveErrorResponse {
|
||||
Error: OneDriveError { Status: response.StatusCode },
|
||||
errorResponse := &OneDriveErrorResponse{
|
||||
Error: OneDriveError{Status: response.StatusCode},
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(response.Body).Decode(errorResponse); err != nil {
|
||||
return nil, 0, OneDriveError { Status: response.StatusCode, Message: fmt.Sprintf("Unexpected response"), }
|
||||
}
|
||||
|
||||
errorResponse.Error.Status = response.StatusCode
|
||||
|
||||
if response.StatusCode == 401 {
|
||||
|
||||
if url == OneDriveRefreshTokenURL {
|
||||
return nil, 0, OneDriveError { Status: response.StatusCode, Message: "Authorization error when refreshing token"}
|
||||
return nil, 0, OneDriveError{Status: response.StatusCode, Message: "Authorization error when refreshing token"}
|
||||
}
|
||||
|
||||
err = client.RefreshToken()
|
||||
err = client.RefreshToken(true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
continue
|
||||
} else if response.StatusCode == 500 || response.StatusCode == 503 || response.StatusCode == 509 {
|
||||
} else if response.StatusCode > 401 && response.StatusCode != 404 {
|
||||
retryAfter := time.Duration(rand.Float32() * 1000.0 * float32(backoff))
|
||||
LOG_INFO("ONEDRIVE_RETRY", "Response status: %d; retry after %d milliseconds", response.StatusCode, retryAfter)
|
||||
LOG_INFO("ONEDRIVE_RETRY", "Response code: %d; retry after %d milliseconds", response.StatusCode, retryAfter)
|
||||
time.Sleep(retryAfter * time.Millisecond)
|
||||
backoff *= 2
|
||||
continue
|
||||
} else {
|
||||
if err := json.NewDecoder(response.Body).Decode(errorResponse); err != nil {
|
||||
return nil, 0, OneDriveError{Status: response.StatusCode, Message: fmt.Sprintf("Unexpected response")}
|
||||
}
|
||||
|
||||
errorResponse.Error.Status = response.StatusCode
|
||||
return nil, 0, errorResponse.Error
|
||||
}
|
||||
}
|
||||
@@ -159,17 +180,17 @@ func (client *OneDriveClient) call(url string, method string, input interface{},
|
||||
return nil, 0, fmt.Errorf("Maximum number of retries reached")
|
||||
}
|
||||
|
||||
func (client *OneDriveClient) RefreshToken() (err error) {
|
||||
func (client *OneDriveClient) RefreshToken(force bool) (err error) {
|
||||
client.TokenLock.Lock()
|
||||
defer client.TokenLock.Unlock()
|
||||
|
||||
if client.Token.Valid() {
|
||||
if !force && client.Token.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
readCloser, _, err := client.call(OneDriveRefreshTokenURL, "POST", client.Token, "")
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to refresh the access token: %v", err)
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
@@ -194,7 +215,7 @@ func (client *OneDriveClient) RefreshToken() (err error) {
|
||||
type OneDriveEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Folder map[string] interface {}
|
||||
Folder map[string]interface{}
|
||||
Size int64
|
||||
}
|
||||
|
||||
@@ -226,7 +247,7 @@ func (client *OneDriveClient) ListEntries(path string) ([]OneDriveEntry, error)
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
output := &OneDriveListEntriesOutput {}
|
||||
output := &OneDriveListEntriesOutput{}
|
||||
|
||||
if err = json.NewDecoder(readCloser).Decode(&output); err != nil {
|
||||
return nil, err
|
||||
@@ -321,7 +342,7 @@ func (client *OneDriveClient) MoveFile(path string, parent string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *OneDriveClient) CreateDirectory(path string, name string) (error) {
|
||||
func (client *OneDriveClient) CreateDirectory(path string, name string) error {
|
||||
|
||||
url := OneDriveAPIURL + "/root/children"
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"fmt"
|
||||
"testing"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
@@ -30,7 +30,6 @@ func TestOneDriveClient(t *testing.T) {
|
||||
fmt.Printf("name: %s, isDir: %t\n", file.Name, len(file.Folder) != 0)
|
||||
}
|
||||
|
||||
|
||||
testID, _, _, err := oneDriveClient.GetFileInfo("test")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to list the test directory: %v", err)
|
||||
@@ -74,7 +73,7 @@ func TestOneDriveClient(t *testing.T) {
|
||||
maxFileSize := 64 * 1024
|
||||
|
||||
for i := 0; i < numberOfFiles; i++ {
|
||||
content := make([]byte, rand.Int() % maxFileSize + 1)
|
||||
content := make([]byte, rand.Int()%maxFileSize+1)
|
||||
_, err = crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -87,7 +86,7 @@ func TestOneDriveClient(t *testing.T) {
|
||||
|
||||
fmt.Printf("file: %s\n", filename)
|
||||
|
||||
err = oneDriveClient.UploadFile("test/test1/" + filename, content, 100)
|
||||
err = oneDriveClient.UploadFile("test/test1/"+filename, content, 100)
|
||||
if err != nil {
|
||||
/*if e, ok := err.(ACDError); !ok || e.Status != 409 */ {
|
||||
t.Errorf("Failed to upload the file %s: %v", filename, err)
|
||||
@@ -103,7 +102,7 @@ func TestOneDriveClient(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
err = oneDriveClient.MoveFile("test/test1/" + entry.Name, "test/test2")
|
||||
err = oneDriveClient.MoveFile("test/test1/"+entry.Name, "test/test2")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to move %s: %v", entry.Name, err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type OneDriveStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
client *OneDriveClient
|
||||
storageDir string
|
||||
@@ -21,8 +21,8 @@ type OneDriveStorage struct {
|
||||
// CreateOneDriveStorage creates an OneDrive storage object.
|
||||
func CreateOneDriveStorage(tokenFile string, storagePath string, threads int) (storage *OneDriveStorage, err error) {
|
||||
|
||||
for len(storagePath) > 0 && storagePath[len(storagePath) - 1] == '/' {
|
||||
storagePath = storagePath[:len(storagePath) - 1]
|
||||
for len(storagePath) > 0 && storagePath[len(storagePath)-1] == '/' {
|
||||
storagePath = storagePath[:len(storagePath)-1]
|
||||
}
|
||||
|
||||
client, err := NewOneDriveClient(tokenFile)
|
||||
@@ -43,13 +43,13 @@ func CreateOneDriveStorage(tokenFile string, storagePath string, threads int) (s
|
||||
return nil, fmt.Errorf("Path '%s' is not a directory", storagePath)
|
||||
}
|
||||
|
||||
storage = &OneDriveStorage {
|
||||
storage = &OneDriveStorage{
|
||||
client: client,
|
||||
storageDir: storagePath,
|
||||
numberOfThread: threads,
|
||||
}
|
||||
|
||||
for _, path := range []string { "chunks", "fossils", "snapshots" } {
|
||||
for _, path := range []string{"chunks", "fossils", "snapshots"} {
|
||||
dir := storagePath + "/" + path
|
||||
dirID, isDir, _, err := client.GetFileInfo(dir)
|
||||
if err != nil {
|
||||
@@ -65,14 +65,23 @@ func CreateOneDriveStorage(tokenFile string, storagePath string, threads int) (s
|
||||
}
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
|
||||
}
|
||||
|
||||
func (storage *OneDriveStorage) convertFilePath(filePath string) string {
|
||||
if strings.HasPrefix(filePath, "chunks/") && strings.HasSuffix(filePath, ".fsl") {
|
||||
return "fossils/" + filePath[len("chunks/"):len(filePath)-len(".fsl")]
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *OneDriveStorage) ListFiles(threadIndex int, dir string) ([]string, []int64, error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "snapshots" {
|
||||
@@ -84,7 +93,7 @@ func (storage *OneDriveStorage) ListFiles(threadIndex int, dir string) ([]string
|
||||
subDirs := []string{}
|
||||
for _, entry := range entries {
|
||||
if len(entry.Folder) > 0 {
|
||||
subDirs = append(subDirs, entry.Name + "/")
|
||||
subDirs = append(subDirs, entry.Name+"/")
|
||||
}
|
||||
}
|
||||
return subDirs, nil, nil
|
||||
@@ -105,19 +114,29 @@ func (storage *OneDriveStorage) ListFiles(threadIndex int, dir string) ([]string
|
||||
} else {
|
||||
files := []string{}
|
||||
sizes := []int64{}
|
||||
for _, parent := range []string {"chunks", "fossils" } {
|
||||
parents := []string{"chunks", "fossils"}
|
||||
for i := 0; i < len(parents); i++ {
|
||||
parent := parents[i]
|
||||
entries, err := storage.client.ListEntries(storage.storageDir + "/" + parent)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if len(entry.Folder) == 0 {
|
||||
name := entry.Name
|
||||
if parent == "fossils" {
|
||||
name += ".fsl"
|
||||
if strings.HasPrefix(parent, "fossils") {
|
||||
name = parent + "/" + name + ".fsl"
|
||||
name = name[len("fossils/"):]
|
||||
} else {
|
||||
name = parent + "/" + name
|
||||
name = name[len("chunks/"):]
|
||||
}
|
||||
files = append(files, name)
|
||||
sizes = append(sizes, entry.Size)
|
||||
} else {
|
||||
parents = append(parents, parent+"/"+entry.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, sizes, nil
|
||||
@@ -127,9 +146,7 @@ func (storage *OneDriveStorage) ListFiles(threadIndex int, dir string) ([]string
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *OneDriveStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
if strings.HasSuffix(filePath, ".fsl") && strings.HasPrefix(filePath, "chunks/") {
|
||||
filePath = "fossils/" + filePath[len("chunks/"):len(filePath) - len(".fsl")]
|
||||
}
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
|
||||
err = storage.client.DeleteFile(storage.storageDir + "/" + filePath)
|
||||
if e, ok := err.(OneDriveError); ok && e.Status == 404 {
|
||||
@@ -141,14 +158,11 @@ func (storage *OneDriveStorage) DeleteFile(threadIndex int, filePath string) (er
|
||||
|
||||
// MoveFile renames the file.
|
||||
func (storage *OneDriveStorage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
fromPath := storage.storageDir + "/" + from
|
||||
toParent := storage.storageDir + "/fossils"
|
||||
if strings.HasSuffix(from, ".fsl") {
|
||||
fromPath = storage.storageDir + "/fossils/" + from[len("chunks/"):len(from) - len(".fsl")]
|
||||
toParent = storage.storageDir + "/chunks"
|
||||
}
|
||||
|
||||
err = storage.client.MoveFile(fromPath, toParent)
|
||||
fromPath := storage.storageDir + "/" + storage.convertFilePath(from)
|
||||
toPath := storage.storageDir + "/" + storage.convertFilePath(to)
|
||||
|
||||
err = storage.client.MoveFile(fromPath, path.Dir(toPath))
|
||||
if err != nil {
|
||||
if e, ok := err.(OneDriveError); ok && e.Status == 409 {
|
||||
LOG_DEBUG("ONEDRIVE_MOVE", "Ignore 409 conflict error")
|
||||
@@ -161,8 +175,8 @@ func (storage *OneDriveStorage) MoveFile(threadIndex int, from string, to string
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *OneDriveStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
for len(dir) > 0 && dir[len(dir) - 1] == '/' {
|
||||
dir = dir[:len(dir) - 1]
|
||||
for len(dir) > 0 && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
parent := path.Dir(dir)
|
||||
@@ -170,34 +184,23 @@ func (storage *OneDriveStorage) CreateDirectory(threadIndex int, dir string) (er
|
||||
if parent == "." {
|
||||
return storage.client.CreateDirectory(storage.storageDir, dir)
|
||||
} else {
|
||||
return storage.client.CreateDirectory(storage.storageDir + "/" + parent, path.Base(dir))
|
||||
return storage.client.CreateDirectory(storage.storageDir+"/"+parent, path.Base(dir))
|
||||
}
|
||||
}
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *OneDriveStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
|
||||
for len(filePath) > 0 && filePath[len(filePath) - 1] == '/' {
|
||||
filePath = filePath[:len(filePath) - 1]
|
||||
for len(filePath) > 0 && filePath[len(filePath)-1] == '/' {
|
||||
filePath = filePath[:len(filePath)-1]
|
||||
}
|
||||
|
||||
filePath = storage.convertFilePath(filePath)
|
||||
|
||||
fileID, isDir, size, err := storage.client.GetFileInfo(storage.storageDir + "/" + filePath)
|
||||
return fileID != "", isDir, size, err
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *OneDriveStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
filePath = "chunks/" + chunkID
|
||||
realPath := storage.storageDir + "/" + filePath
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
realPath = storage.storageDir + "/fossils/" + chunkID
|
||||
}
|
||||
|
||||
fileID, _, size, err := storage.client.GetFileInfo(realPath)
|
||||
return filePath, fileID != "", size, err
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *OneDriveStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
readCloser, _, err := storage.client.DownloadFile(storage.storageDir + "/" + filePath)
|
||||
@@ -207,13 +210,13 @@ func (storage *OneDriveStorage) DownloadFile(threadIndex int, filePath string, c
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit / storage.numberOfThread)
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.numberOfThread)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *OneDriveStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
err = storage.client.UploadFile(storage.storageDir + "/" + filePath, content, storage.UploadRateLimit / storage.numberOfThread)
|
||||
err = storage.client.UploadFile(storage.storageDir+"/"+filePath, content, storage.UploadRateLimit/storage.numberOfThread)
|
||||
|
||||
if e, ok := err.(OneDriveError); ok && e.Status == 409 {
|
||||
LOG_TRACE("ONEDRIVE_UPLOAD", "File %s already exists", filePath)
|
||||
@@ -225,16 +228,16 @@ func (storage *OneDriveStorage) UploadFile(threadIndex int, filePath string, con
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *OneDriveStorage) IsCacheNeeded() (bool) { return true }
|
||||
func (storage *OneDriveStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *OneDriveStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *OneDriveStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *OneDriveStorage) IsStrongConsistent() (bool) { return false }
|
||||
func (storage *OneDriveStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *OneDriveStorage) IsFastListing() (bool) { return true }
|
||||
func (storage *OneDriveStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *OneDriveStorage) EnableTestMode() {
|
||||
|
||||
@@ -1,76 +1,64 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path"
|
||||
"io/ioutil"
|
||||
"reflect"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Preference stores options for each storage.
|
||||
type Preference struct {
|
||||
Name string `json:"name"`
|
||||
SnapshotID string `json:"id"`
|
||||
RepositoryPath string `json:"repository"`
|
||||
StorageURL string `json:"storage"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
BackupProhibited bool `json:"no_backup"`
|
||||
RestoreProhibited bool `json:"no_restore"`
|
||||
DoNotSavePassword bool `json:"no_save_password"`
|
||||
NobackupFile string `json:"nobackup_file"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
var Preferences [] Preference
|
||||
var preferencePath string
|
||||
var Preferences []Preference
|
||||
|
||||
// Compute .duplicacy directory path name:
|
||||
// - if .duplicacy is a directory -> compute absolute path name and return it
|
||||
// - if .duplicacy is a file -> assumed this file contains the real path name of .duplicacy
|
||||
// - if pointed directory does not exits... return error
|
||||
func GetDuplicacyPreferencePath( repository string) (preferencePath string){
|
||||
func LoadPreferences(repository string) bool {
|
||||
|
||||
preferencePath = path.Join(repository, DUPLICACY_DIRECTORY) //TOKEEP
|
||||
preferencePath = path.Join(repository, DUPLICACY_DIRECTORY)
|
||||
|
||||
stat, err := os.Stat(preferencePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
LOG_ERROR("DOT_DUPLICACY_PATH", "Failed to retrieve the information about the directory %s: %v",
|
||||
repository, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if stat != nil && stat.IsDir() {
|
||||
// $repository/.duplicacy exists and is a directory --> we found the .duplicacy directory
|
||||
return path.Clean(preferencePath)
|
||||
}
|
||||
|
||||
if stat != nil && stat.Mode().IsRegular() {
|
||||
b, err := ioutil.ReadFile(preferencePath) // just pass the file name
|
||||
if err != nil {
|
||||
LOG_ERROR("DOT_DUPLICACY_PATH", "Failed to read file %s: %v",
|
||||
preferencePath, err)
|
||||
return ""
|
||||
LOG_ERROR("PREFERENCE_PATH", "Failed to retrieve the information about the directory %s: %v", repository, err)
|
||||
return false
|
||||
}
|
||||
dotDuplicacyContent := string(b) // convert content to a 'string'
|
||||
stat, err := os.Stat(dotDuplicacyContent)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
LOG_ERROR("DOT_DUPLICACY_PATH", "Failed to retrieve the information about the directory %s: %v",
|
||||
repository, err)
|
||||
return ""
|
||||
}
|
||||
if stat != nil && stat.IsDir() {
|
||||
// If expression read from .duplicacy file is a directory --> we found the .duplicacy directory
|
||||
return path.Clean(dotDuplicacyContent)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func LoadPreferences(repository string) (bool) {
|
||||
if !stat.IsDir() {
|
||||
content, err := ioutil.ReadFile(preferencePath)
|
||||
if err != nil {
|
||||
LOG_ERROR("DOT_DUPLICACY_PATH", "Failed to locate the preference path: %v", err)
|
||||
return false
|
||||
}
|
||||
realPreferencePath := strings.TrimSpace(string(content))
|
||||
stat, err := os.Stat(realPreferencePath)
|
||||
if err != nil {
|
||||
LOG_ERROR("PREFERENCE_PATH", "Failed to retrieve the information about the directory %s: %v", content, err)
|
||||
return false
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
LOG_ERROR("PREFERENCE_PATH", "The preference path %s is not a directory", realPreferencePath)
|
||||
}
|
||||
|
||||
preferencePath = realPreferencePath
|
||||
}
|
||||
|
||||
preferencePath := GetDuplicacyPreferencePath(repository)
|
||||
description, err := ioutil.ReadFile(path.Join(preferencePath, "preferences"))
|
||||
if err != nil {
|
||||
LOG_ERROR("PREFERENCE_OPEN", "Failed to read the preference file from repository %s: %v", repository, err)
|
||||
@@ -88,19 +76,39 @@ func LoadPreferences(repository string) (bool) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, preference := range Preferences {
|
||||
if strings.ToLower(preference.Name) == "ssh" {
|
||||
LOG_ERROR("PREFERENCE_INVALID", "'%s' is an invalid storage name", preference.Name)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func SavePreferences(repository string) (bool) {
|
||||
func GetDuplicacyPreferencePath() string {
|
||||
if preferencePath == "" {
|
||||
LOG_ERROR("PREFERENCE_PATH", "The preference path has not been set")
|
||||
return ""
|
||||
}
|
||||
return preferencePath
|
||||
}
|
||||
|
||||
// Normally 'preferencePath' is set in LoadPreferences; however, if LoadPreferences is not called, this function
|
||||
// provide another change to set 'preferencePath'
|
||||
func SetDuplicacyPreferencePath(p string) {
|
||||
preferencePath = p
|
||||
}
|
||||
|
||||
func SavePreferences() bool {
|
||||
description, err := json.MarshalIndent(Preferences, "", " ")
|
||||
if err != nil {
|
||||
LOG_ERROR("PREFERENCE_MARSHAL", "Failed to marshal the repository preferences: %v", err)
|
||||
return false
|
||||
}
|
||||
preferencePath := GetDuplicacyPreferencePath(repository)
|
||||
preferenceFile := path.Join(preferencePath, "/preferences")
|
||||
preferenceFile := path.Join(GetDuplicacyPreferencePath(), "preferences")
|
||||
|
||||
err = ioutil.WriteFile(preferenceFile, description, 0644)
|
||||
err = ioutil.WriteFile(preferenceFile, description, 0600)
|
||||
if err != nil {
|
||||
LOG_ERROR("PREFERENCE_WRITE", "Failed to save the preference file %s: %v", preferenceFile, err)
|
||||
return false
|
||||
@@ -109,10 +117,10 @@ func SavePreferences(repository string) (bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
func FindPreference(name string) (*Preference) {
|
||||
for _, preference := range Preferences {
|
||||
func FindPreference(name string) *Preference {
|
||||
for i, preference := range Preferences {
|
||||
if preference.Name == name || preference.StorageURL == name {
|
||||
return &preference
|
||||
return &Preferences[i]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
196
src/duplicacy_s3cstorage.go
Normal file
196
src/duplicacy_s3cstorage.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gilbertchen/goamz/aws"
|
||||
"github.com/gilbertchen/goamz/s3"
|
||||
)
|
||||
|
||||
// S3CStorage is a storage backend for s3 compatible storages that require V2 Signing.
|
||||
type S3CStorage struct {
|
||||
StorageBase
|
||||
|
||||
buckets []*s3.Bucket
|
||||
storageDir string
|
||||
}
|
||||
|
||||
// CreateS3CStorage creates a amazon s3 storage object.
|
||||
func CreateS3CStorage(regionName string, endpoint string, bucketName string, storageDir string,
|
||||
accessKey string, secretKey string, threads int) (storage *S3CStorage, err error) {
|
||||
|
||||
var region aws.Region
|
||||
|
||||
if endpoint == "" {
|
||||
if regionName == "" {
|
||||
regionName = "us-east-1"
|
||||
}
|
||||
region = aws.Regions[regionName]
|
||||
} else {
|
||||
region = aws.Region{Name: regionName, S3Endpoint: "https://" + endpoint}
|
||||
}
|
||||
|
||||
auth := aws.Auth{AccessKey: accessKey, SecretKey: secretKey}
|
||||
|
||||
var buckets []*s3.Bucket
|
||||
for i := 0; i < threads; i++ {
|
||||
s3Client := s3.New(auth, region)
|
||||
s3Client.AttemptStrategy = aws.AttemptStrategy{
|
||||
Min: 8,
|
||||
Total: 300 * time.Second,
|
||||
Delay: 1000 * time.Millisecond,
|
||||
}
|
||||
|
||||
bucket := s3Client.Bucket(bucketName)
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir)-1] != '/' {
|
||||
storageDir += "/"
|
||||
}
|
||||
|
||||
storage = &S3CStorage{
|
||||
buckets: buckets,
|
||||
storageDir: storageDir,
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *S3CStorage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {
|
||||
if len(dir) > 0 && dir[len(dir)-1] != '/' {
|
||||
dir += "/"
|
||||
}
|
||||
|
||||
dirLength := len(storage.storageDir + dir)
|
||||
if dir == "snapshots/" {
|
||||
results, err := storage.buckets[threadIndex].List(storage.storageDir+dir, "/", "", 100)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, subDir := range results.CommonPrefixes {
|
||||
files = append(files, subDir[dirLength:])
|
||||
}
|
||||
return files, nil, nil
|
||||
} else if dir == "chunks/" {
|
||||
marker := ""
|
||||
for {
|
||||
results, err := storage.buckets[threadIndex].List(storage.storageDir+dir, "", marker, 1000)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, object := range results.Contents {
|
||||
files = append(files, object.Key[dirLength:])
|
||||
sizes = append(sizes, object.Size)
|
||||
}
|
||||
|
||||
if !results.IsTruncated {
|
||||
break
|
||||
}
|
||||
|
||||
marker = results.Contents[len(results.Contents)-1].Key
|
||||
}
|
||||
return files, sizes, nil
|
||||
|
||||
} else {
|
||||
|
||||
results, err := storage.buckets[threadIndex].List(storage.storageDir+dir, "", "", 1000)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, object := range results.Contents {
|
||||
files = append(files, object.Key[dirLength:])
|
||||
}
|
||||
return files, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *S3CStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
return storage.buckets[threadIndex].Del(storage.storageDir + filePath)
|
||||
}
|
||||
|
||||
// MoveFile renames the file.
|
||||
func (storage *S3CStorage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
|
||||
options := s3.CopyOptions{ContentType: "application/duplicacy"}
|
||||
_, err = storage.buckets[threadIndex].PutCopy(storage.storageDir+to, s3.Private, options, storage.buckets[threadIndex].Name+"/"+storage.storageDir+from)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return storage.DeleteFile(threadIndex, from)
|
||||
}
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *S3CStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *S3CStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
|
||||
response, err := storage.buckets[threadIndex].Head(storage.storageDir+filePath, nil)
|
||||
if err != nil {
|
||||
if e, ok := err.(*s3.Error); ok && (e.StatusCode == 403 || e.StatusCode == 404) {
|
||||
return false, false, 0, nil
|
||||
} else {
|
||||
return false, false, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if response.StatusCode == 403 || response.StatusCode == 404 {
|
||||
return false, false, 0, nil
|
||||
} else {
|
||||
return true, false, response.ContentLength, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *S3CStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
readCloser, err := storage.buckets[threadIndex].GetReader(storage.storageDir + filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer readCloser.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/len(storage.buckets))
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *S3CStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
|
||||
options := s3.Options{}
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/len(storage.buckets))
|
||||
return storage.buckets[threadIndex].PutReader(storage.storageDir+filePath, reader, int64(len(content)), "application/duplicacy", s3.Private, options)
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *S3CStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *S3CStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *S3CStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *S3CStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *S3CStorage) EnableTestMode() {}
|
||||
@@ -1,10 +1,18 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
// NOTE: The code in the Wasabi storage module relies on all functions
|
||||
// in this one except MoveFile(), IsMoveFileImplemented() and
|
||||
// IsStrongConsistent(). Changes to the API here will need to be
|
||||
// reflected there.
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
@@ -13,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
client *s3.S3
|
||||
bucket string
|
||||
@@ -23,14 +31,15 @@ type S3Storage struct {
|
||||
|
||||
// CreateS3Storage creates a amazon s3 storage object.
|
||||
func CreateS3Storage(regionName string, endpoint string, bucketName string, storageDir string,
|
||||
accessKey string, secretKey string, threads int) (storage *S3Storage, err error) {
|
||||
accessKey string, secretKey string, threads int,
|
||||
isSSLSupported bool, isMinioCompatible bool) (storage *S3Storage, err error) {
|
||||
|
||||
token := ""
|
||||
|
||||
auth := credentials.NewStaticCredentials(accessKey, secretKey, token)
|
||||
|
||||
if regionName == "" && endpoint == "" {
|
||||
defaultRegionConfig := &aws.Config {
|
||||
defaultRegionConfig := &aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Credentials: auth,
|
||||
}
|
||||
@@ -49,35 +58,39 @@ func CreateS3Storage(regionName string, endpoint string, bucketName string, stor
|
||||
}
|
||||
}
|
||||
|
||||
config := &aws.Config {
|
||||
s3Config := &aws.Config{
|
||||
Region: aws.String(regionName),
|
||||
Credentials: auth,
|
||||
Endpoint: aws.String(endpoint),
|
||||
S3ForcePathStyle: aws.Bool(isMinioCompatible),
|
||||
DisableSSL: aws.Bool(!isSSLSupported),
|
||||
}
|
||||
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir) - 1] != '/' {
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir)-1] != '/' {
|
||||
storageDir += "/"
|
||||
}
|
||||
|
||||
storage = &S3Storage {
|
||||
client: s3.New(session.New(config)),
|
||||
storage = &S3Storage{
|
||||
client: s3.New(session.New(s3Config)),
|
||||
bucket: bucketName,
|
||||
storageDir: storageDir,
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *S3Storage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {
|
||||
if len(dir) > 0 && dir[len(dir) - 1] != '/' {
|
||||
if len(dir) > 0 && dir[len(dir)-1] != '/' {
|
||||
dir += "/"
|
||||
}
|
||||
|
||||
if dir == "snapshots/" {
|
||||
dir = storage.storageDir + dir
|
||||
input := s3.ListObjectsInput {
|
||||
input := s3.ListObjectsInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Prefix: aws.String(dir),
|
||||
Delimiter: aws.String("/"),
|
||||
@@ -97,7 +110,7 @@ func (storage *S3Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
dir = storage.storageDir + dir
|
||||
marker := ""
|
||||
for {
|
||||
input := s3.ListObjectsInput {
|
||||
input := s3.ListObjectsInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Prefix: aws.String(dir),
|
||||
MaxKeys: aws.Int64(1000),
|
||||
@@ -118,7 +131,7 @@ func (storage *S3Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
break
|
||||
}
|
||||
|
||||
marker = *output.Contents[len(output.Contents) - 1].Key
|
||||
marker = *output.Contents[len(output.Contents)-1].Key
|
||||
}
|
||||
return files, sizes, nil
|
||||
}
|
||||
@@ -127,7 +140,7 @@ func (storage *S3Storage) ListFiles(threadIndex int, dir string) (files []string
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *S3Storage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
input := &s3.DeleteObjectInput {
|
||||
input := &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Key: aws.String(storage.storageDir + filePath),
|
||||
}
|
||||
@@ -138,7 +151,7 @@ func (storage *S3Storage) DeleteFile(threadIndex int, filePath string) (err erro
|
||||
// MoveFile renames the file.
|
||||
func (storage *S3Storage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
|
||||
input := &s3.CopyObjectInput {
|
||||
input := &s3.CopyObjectInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
CopySource: aws.String(storage.bucket + "/" + storage.storageDir + from),
|
||||
Key: aws.String(storage.storageDir + to),
|
||||
@@ -161,7 +174,7 @@ func (storage *S3Storage) CreateDirectory(threadIndex int, dir string) (err erro
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *S3Storage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
|
||||
input := &s3.HeadObjectInput {
|
||||
input := &s3.HeadObjectInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Key: aws.String(storage.storageDir + filePath),
|
||||
}
|
||||
@@ -182,29 +195,10 @@ func (storage *S3Storage) GetFileInfo(threadIndex int, filePath string) (exist b
|
||||
}
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *S3Storage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
|
||||
filePath = "chunks/" + chunkID
|
||||
if isFossil {
|
||||
filePath += ".fsl"
|
||||
}
|
||||
|
||||
exist, _, size, err = storage.GetFileInfo(threadIndex, filePath)
|
||||
|
||||
if err != nil {
|
||||
return "", false, 0, err
|
||||
} else {
|
||||
return filePath, exist, size, err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *S3Storage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
input := &s3.GetObjectInput {
|
||||
input := &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Key: aws.String(storage.storageDir + filePath),
|
||||
}
|
||||
@@ -216,7 +210,7 @@ func (storage *S3Storage) DownloadFile(threadIndex int, filePath string, chunk *
|
||||
|
||||
defer output.Body.Close()
|
||||
|
||||
_, err = RateLimitedCopy(chunk, output.Body, storage.DownloadRateLimit / len(storage.bucket))
|
||||
_, err = RateLimitedCopy(chunk, output.Body, storage.DownloadRateLimit/len(storage.bucket))
|
||||
return err
|
||||
|
||||
}
|
||||
@@ -224,30 +218,41 @@ func (storage *S3Storage) DownloadFile(threadIndex int, filePath string, chunk *
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *S3Storage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
|
||||
input := &s3.PutObjectInput {
|
||||
attempts := 0
|
||||
|
||||
for {
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(storage.bucket),
|
||||
Key: aws.String(storage.storageDir + filePath),
|
||||
ACL: aws.String(s3.ObjectCannedACLPrivate),
|
||||
Body: CreateRateLimitedReader(content, storage.UploadRateLimit / len(storage.bucket)),
|
||||
Body: CreateRateLimitedReader(content, storage.UploadRateLimit/len(storage.bucket)),
|
||||
ContentType: aws.String("application/duplicacy"),
|
||||
}
|
||||
|
||||
_, err = storage.client.PutObject(input)
|
||||
if err == nil || attempts >= 3 || !strings.Contains(err.Error(), "XAmzContentSHA256Mismatch") {
|
||||
return err
|
||||
}
|
||||
|
||||
LOG_INFO("S3_RETRY", "Retrying on %s: %v", reflect.TypeOf(err), err)
|
||||
attempts += 1
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *S3Storage) IsCacheNeeded () (bool) { return true }
|
||||
func (storage *S3Storage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *S3Storage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *S3Storage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *S3Storage) IsStrongConsistent() (bool) { return false }
|
||||
func (storage *S3Storage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *S3Storage) IsFastListing() (bool) { return true }
|
||||
func (storage *S3Storage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *S3Storage) EnableTestMode() {}
|
||||
|
||||
@@ -1,62 +1,63 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"net"
|
||||
"path"
|
||||
"time"
|
||||
"runtime"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SFTPStorage struct {
|
||||
RateLimitedStorage
|
||||
StorageBase
|
||||
|
||||
client *sftp.Client
|
||||
minimumNesting int // The minimum level of directories to dive into before searching for the chunk file.
|
||||
storageDir string
|
||||
numberOfThreads int
|
||||
}
|
||||
|
||||
func CreateSFTPStorageWithPassword(server string, port int, username string, storageDir string,
|
||||
password string, threads int) (storage *SFTPStorage, err error) {
|
||||
|
||||
authMethods := [] ssh.AuthMethod { ssh.Password(password) }
|
||||
minimumNesting int, password string, threads int) (storage *SFTPStorage, err error) {
|
||||
|
||||
authMethods := []ssh.AuthMethod{ssh.Password(password)}
|
||||
|
||||
hostKeyCallback := func(hostname string, remote net.Addr,
|
||||
key ssh.PublicKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CreateSFTPStorage(server, port, username, storageDir, authMethods, hostKeyCallback, threads)
|
||||
return CreateSFTPStorage(server, port, username, storageDir, minimumNesting, authMethods, hostKeyCallback, threads)
|
||||
}
|
||||
|
||||
func CreateSFTPStorage(server string, port int, username string, storageDir string,
|
||||
authMethods [] ssh.AuthMethod,
|
||||
func CreateSFTPStorage(server string, port int, username string, storageDir string, minimumNesting int,
|
||||
authMethods []ssh.AuthMethod,
|
||||
hostKeyCallback func(hostname string, remote net.Addr,
|
||||
key ssh.PublicKey) error, threads int) (storage *SFTPStorage, err error) {
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
sftpConfig := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
}
|
||||
|
||||
if server == "sftp.hidrive.strato.com" {
|
||||
config.Ciphers = []string {"aes128-cbc", "aes128-ctr", "aes256-ctr"}
|
||||
sftpConfig.Ciphers = []string{"aes128-ctr", "aes256-ctr"}
|
||||
}
|
||||
|
||||
serverAddress := fmt.Sprintf("%s:%d", server, port)
|
||||
connection, err := ssh.Dial("tcp", serverAddress, config)
|
||||
connection, err := ssh.Dial("tcp", serverAddress, sftpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -67,8 +68,8 @@ func CreateSFTPStorage(server string, port int, username string, storageDir stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for storageDir[len(storageDir) - 1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir) - 1]
|
||||
for storageDir[len(storageDir)-1] == '/' {
|
||||
storageDir = storageDir[:len(storageDir)-1]
|
||||
}
|
||||
|
||||
fileInfo, err := client.Stat(storageDir)
|
||||
@@ -80,9 +81,10 @@ func CreateSFTPStorage(server string, port int, username string, storageDir stri
|
||||
return nil, fmt.Errorf("The storage path %s is not a directory", storageDir)
|
||||
}
|
||||
|
||||
storage = &SFTPStorage {
|
||||
storage = &SFTPStorage{
|
||||
client: client,
|
||||
storageDir: storageDir,
|
||||
minimumNesting: minimumNesting,
|
||||
numberOfThreads: threads,
|
||||
}
|
||||
|
||||
@@ -91,6 +93,8 @@ func CreateSFTPStorage(server string, port int, username string, storageDir stri
|
||||
|
||||
runtime.SetFinalizer(storage, CloseSFTPStorage)
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ func (storage *SFTPStorage) ListFiles(threadIndex int, dirPath string) (files []
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() && name[len(name) - 1] != '/' {
|
||||
if entry.IsDir() && name[len(name)-1] != '/' {
|
||||
name += "/"
|
||||
}
|
||||
|
||||
@@ -175,67 +179,6 @@ func (storage *SFTPStorage) GetFileInfo(threadIndex int, filePath string) (exist
|
||||
return true, fileInfo.IsDir(), fileInfo.Size(), nil
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id. If 'isFossil' is true, it will search for chunk files with
|
||||
// the suffix '.fsl'.
|
||||
func (storage *SFTPStorage) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
dir := path.Join(storage.storageDir, "chunks")
|
||||
|
||||
suffix := ""
|
||||
if isFossil {
|
||||
suffix = ".fsl"
|
||||
}
|
||||
|
||||
// The minimum level of directories to dive into before searching for the chunk file.
|
||||
minimumLevel := 2
|
||||
|
||||
for level := 0; level * 2 < len(chunkID); level ++ {
|
||||
if level >= minimumLevel {
|
||||
filePath = path.Join(dir, chunkID[2 * level:]) + suffix
|
||||
if stat, err := storage.client.Stat(filePath); err == nil && !stat.IsDir() {
|
||||
return filePath[len(storage.storageDir) + 1:], true, stat.Size(), nil
|
||||
} else if err == nil && stat.IsDir() {
|
||||
return filePath[len(storage.storageDir) + 1:], true, 0, fmt.Errorf("The path %s is a directory", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the subdirectory the chunk file may reside.
|
||||
subDir := path.Join(dir, chunkID[2 * level: 2 * level + 2])
|
||||
stat, err := storage.client.Stat(subDir)
|
||||
if err == nil && stat.IsDir() {
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
if level < minimumLevel {
|
||||
// Create the subdirectory if is doesn't exist.
|
||||
|
||||
if err == nil && !stat.IsDir() {
|
||||
return "", false, 0, fmt.Errorf("The path %s is not a directory", subDir)
|
||||
}
|
||||
|
||||
err = storage.client.Mkdir(subDir)
|
||||
if err != nil {
|
||||
// The directory may have been created by other threads so check it again.
|
||||
stat, _ := storage.client.Stat(subDir)
|
||||
if stat == nil || !stat.IsDir() {
|
||||
return "", false, 0, fmt.Errorf("Failed to create the directory %s: %v", subDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
dir = subDir
|
||||
continue
|
||||
}
|
||||
|
||||
// Teh chunk must be under this subdirectory but it doesn't exist.
|
||||
return path.Join(dir, chunkID[2 * level:])[len(storage.storageDir) + 1:] + suffix, false, 0, nil
|
||||
|
||||
}
|
||||
|
||||
LOG_FATAL("CHUNK_FIND", "Chunk %s is still not found after having searched a maximum level of directories",
|
||||
chunkID)
|
||||
return "", false, 0, nil
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *SFTPStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
file, err := storage.client.Open(path.Join(storage.storageDir, filePath))
|
||||
@@ -245,7 +188,7 @@ func (storage *SFTPStorage) DownloadFile(threadIndex int, filePath string, chunk
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
if _, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit / storage.numberOfThreads); err != nil {
|
||||
if _, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit/storage.numberOfThreads); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -257,6 +200,30 @@ func (storage *SFTPStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
fullPath := path.Join(storage.storageDir, filePath)
|
||||
|
||||
dirs := strings.Split(filePath, "/")
|
||||
if len(dirs) > 1 {
|
||||
fullDir := path.Dir(fullPath)
|
||||
_, err := storage.client.Stat(fullDir)
|
||||
if err != nil {
|
||||
// The error may be caused by a non-existent fullDir, or a broken connection. In either case,
|
||||
// we just assume it is the former because there isn't a way to tell which is the case.
|
||||
for i, _ := range dirs[1 : len(dirs)-1] {
|
||||
subDir := path.Join(storage.storageDir, path.Join(dirs[0:i+2]...))
|
||||
// We don't check the error; just keep going blindly but always store the last err
|
||||
err = storage.client.Mkdir(subDir)
|
||||
}
|
||||
|
||||
// If there is an error creating the dirs, we check fullDir one more time, because another thread
|
||||
// may happen to create the same fullDir ahead of this thread
|
||||
if err != nil {
|
||||
_, err := storage.client.Stat(fullDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
letters := "abcdefghijklmnopqrstuvwxyz"
|
||||
suffix := make([]byte, 8)
|
||||
for i := range suffix {
|
||||
@@ -265,12 +232,12 @@ func (storage *SFTPStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
temporaryFile := fullPath + "." + string(suffix) + ".tmp"
|
||||
|
||||
file, err := storage.client.OpenFile(temporaryFile, os.O_WRONLY | os.O_CREATE | os.O_TRUNC)
|
||||
file, err := storage.client.OpenFile(temporaryFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit / storage.numberOfThreads)
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.numberOfThreads)
|
||||
_, err = io.Copy(file, reader)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
@@ -285,7 +252,7 @@ func (storage *SFTPStorage) UploadFile(threadIndex int, filePath string, content
|
||||
storage.client.Remove(temporaryFile)
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("Uploaded file but failed to store it at %s", fullPath)
|
||||
return fmt.Errorf("Uploaded file but failed to store it at %s: %v", fullPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,16 +261,23 @@ func (storage *SFTPStorage) UploadFile(threadIndex int, filePath string, content
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *SFTPStorage) IsCacheNeeded () (bool) { return true }
|
||||
func (storage *SFTPStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *SFTPStorage) IsMoveFileImplemented() (bool) { return true }
|
||||
func (storage *SFTPStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *SFTPStorage) IsStrongConsistent() (bool) { return true }
|
||||
func (storage *SFTPStorage) IsStrongConsistent() bool { return true }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *SFTPStorage) IsFastListing() (bool) { return false }
|
||||
func (storage *SFTPStorage) IsFastListing() bool {
|
||||
for _, level := range storage.readLevels {
|
||||
if level > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *SFTPStorage) EnableTestMode() {}
|
||||
|
||||
7
src/duplicacy_shadowcopy.go
Normal file → Executable file
7
src/duplicacy_shadowcopy.go
Normal file → Executable file
@@ -1,12 +1,13 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
// +build !windows
|
||||
// +build !darwin
|
||||
|
||||
package duplicacy
|
||||
|
||||
func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
func CreateShadowCopy(top string, shadowCopy bool, timeoutInSeconds int) (shadowTop string) {
|
||||
return top
|
||||
}
|
||||
|
||||
|
||||
170
src/duplicacy_shadowcopy_darwin.go
Executable file
170
src/duplicacy_shadowcopy_darwin.go
Executable file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// Shadow copy module for Mac OSX using APFS snapshot
|
||||
//
|
||||
//
|
||||
// This module copyright 2018 Adam Marcus (https://github.com/amarcu5)
|
||||
// and may be distributed under the same terms as Duplicacy.
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var snapshotPath string
|
||||
var snapshotDate string
|
||||
|
||||
// Converts char array to string
|
||||
func CharsToString(ca []int8) string {
|
||||
|
||||
len := len(ca)
|
||||
ba := make([]byte, len)
|
||||
|
||||
for i, v := range ca {
|
||||
ba[i] = byte(v)
|
||||
if ba[i] == 0 {
|
||||
len = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return string(ba[:len])
|
||||
}
|
||||
|
||||
// Get ID of device containing path
|
||||
func GetPathDeviceId(path string) (deviceId int32, err error) {
|
||||
|
||||
stat := syscall.Stat_t{}
|
||||
|
||||
err = syscall.Stat(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return stat.Dev, nil
|
||||
}
|
||||
|
||||
// Executes shell command with timeout and returns stdout
|
||||
func CommandWithTimeout(timeoutInSeconds int, name string, arg ...string) (output string, err error) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutInSeconds) * time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, name, arg...)
|
||||
out, err := cmd.Output()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
err = errors.New("Command '" + name + "' timed out")
|
||||
}
|
||||
|
||||
output = string(out)
|
||||
return output, err
|
||||
}
|
||||
|
||||
func DeleteShadowCopy() {
|
||||
|
||||
if snapshotPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := exec.Command("/sbin/umount", "-f", snapshotPath).Run()
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_DELETE", "Error while unmounting snapshot")
|
||||
return
|
||||
}
|
||||
|
||||
err = exec.Command("tmutil", "deletelocalsnapshots", snapshotDate).Run()
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_DELETE", "Error while deleting local snapshot")
|
||||
return
|
||||
}
|
||||
|
||||
err = os.RemoveAll(snapshotPath)
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_DELETE", "Error while deleting temporary mount directory")
|
||||
return
|
||||
}
|
||||
|
||||
LOG_INFO("VSS_DELETE", "Shadow copy unmounted and deleted at %s", snapshotPath)
|
||||
|
||||
snapshotPath = ""
|
||||
}
|
||||
|
||||
func CreateShadowCopy(top string, shadowCopy bool, timeoutInSeconds int) (shadowTop string) {
|
||||
|
||||
if !shadowCopy {
|
||||
return top
|
||||
}
|
||||
|
||||
// Check repository filesystem is APFS
|
||||
stat := syscall.Statfs_t{}
|
||||
err := syscall.Statfs(top, &stat)
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_INIT", "Unable to determine filesystem of repository path")
|
||||
return top
|
||||
}
|
||||
if CharsToString(stat.Fstypename[:]) != "apfs" {
|
||||
LOG_WARN("VSS_INIT", "VSS requires APFS filesystem")
|
||||
return top
|
||||
}
|
||||
|
||||
// Check path is local as tmutil snapshots will not support APFS formatted external drives
|
||||
deviceIdLocal, err := GetPathDeviceId("/")
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_INIT", "Unable to get device ID of path: /")
|
||||
return top
|
||||
}
|
||||
deviceIdRepository, err := GetPathDeviceId(top)
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_INIT", "Unable to get device ID of path: ", top)
|
||||
return top
|
||||
}
|
||||
if deviceIdLocal != deviceIdRepository {
|
||||
LOG_WARN("VSS_PATH", "VSS not supported for non-local repository path: ", top)
|
||||
return top
|
||||
}
|
||||
|
||||
if timeoutInSeconds <= 60 {
|
||||
timeoutInSeconds = 60
|
||||
}
|
||||
|
||||
// Create mount point
|
||||
snapshotPath, err = ioutil.TempDir("/tmp/", "snp_")
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_CREATE", "Failed to create temporary mount directory")
|
||||
return top
|
||||
}
|
||||
|
||||
// Use tmutil to create snapshot
|
||||
tmutilOutput, err := CommandWithTimeout(timeoutInSeconds, "tmutil", "snapshot")
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_CREATE", "Error while calling tmutil: ", err)
|
||||
return top
|
||||
}
|
||||
|
||||
colonPos := strings.IndexByte(tmutilOutput, ':')
|
||||
if colonPos < 0 {
|
||||
LOG_ERROR("VSS_CREATE", "Snapshot creation failed: ", tmutilOutput)
|
||||
return top
|
||||
}
|
||||
snapshotDate = strings.TrimSpace(tmutilOutput[colonPos+1:])
|
||||
|
||||
// Mount snapshot as readonly and hide from GUI i.e. Finder
|
||||
_, err = CommandWithTimeout(timeoutInSeconds,
|
||||
"/sbin/mount", "-t", "apfs", "-o", "nobrowse,-r,-s=com.apple.TimeMachine." + snapshotDate, "/", snapshotPath)
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_CREATE", "Error while mounting snapshot: ", err)
|
||||
return top
|
||||
}
|
||||
|
||||
LOG_INFO("VSS_DONE", "Shadow copy created and mounted at %s", snapshotPath)
|
||||
|
||||
return snapshotPath + top
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
"time"
|
||||
"os"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
ole "github.com/gilbertchen/go-ole"
|
||||
)
|
||||
@@ -28,7 +28,7 @@ type IVSSAsyncVtbl struct {
|
||||
queryStatus uintptr
|
||||
}
|
||||
|
||||
func (async *IVSSAsync) VTable() * IVSSAsyncVtbl {
|
||||
func (async *IVSSAsync) VTable() *IVSSAsyncVtbl {
|
||||
return (*IVSSAsyncVtbl)(unsafe.Pointer(async.RawVTable))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func (async *IVSSAsync) Wait(seconds int) bool {
|
||||
if status == VSS_S_ASYNC_FINISHED {
|
||||
return true
|
||||
}
|
||||
if time.Now().Unix() - startTime > int64(seconds) {
|
||||
if time.Now().Unix()-startTime > int64(seconds) {
|
||||
LOG_WARN("IVSSASYNC_TIMEOUT", "IVssAsync is pending for more than %d seconds\n", seconds)
|
||||
return false
|
||||
}
|
||||
@@ -77,7 +77,6 @@ func getIVSSAsync(unknown *ole.IUnknown, iid *ole.GUID) (async *IVSSAsync) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
//665c1d5f-c218-414d-a05d-7fef5f9d5c86
|
||||
var IID_IVSS = &ole.GUID{0x665c1d5f, 0xc218, 0x414d, [8]byte{0xa0, 0x5d, 0x7f, 0xef, 0x5f, 0x9d, 0x5c, 0x86}}
|
||||
|
||||
@@ -137,7 +136,7 @@ type IVSSVtbl struct {
|
||||
queryRevertStatus uintptr
|
||||
}
|
||||
|
||||
func (vss *IVSS) VTable() * IVSSVtbl {
|
||||
func (vss *IVSS) VTable() *IVSSVtbl {
|
||||
return (*IVSSVtbl)(unsafe.Pointer(vss.RawVTable))
|
||||
}
|
||||
|
||||
@@ -238,7 +237,7 @@ type SnapshotProperties struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (vss *IVSS) GetSnapshotProperties(snapshotSetID ole.GUID, properties *SnapshotProperties) (int) {
|
||||
func (vss *IVSS) GetSnapshotProperties(snapshotSetID ole.GUID, properties *SnapshotProperties) int {
|
||||
var ret uintptr
|
||||
if runtime.GOARCH == "386" {
|
||||
address := uint(uintptr(unsafe.Pointer(&snapshotSetID)))
|
||||
@@ -292,8 +291,7 @@ func (vss *IVSS) DeleteSnapshots(snapshotID ole.GUID) (int, int, ole.GUID) {
|
||||
return int(ret), int(deleted), deletedGUID
|
||||
}
|
||||
|
||||
|
||||
func uint16ArrayToString(p *uint16) (string) {
|
||||
func uint16ArrayToString(p *uint16) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -355,12 +353,15 @@ func DeleteShadowCopy() {
|
||||
ole.CoUninitialize()
|
||||
}
|
||||
|
||||
func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
func CreateShadowCopy(top string, shadowCopy bool, timeoutInSeconds int) (shadowTop string) {
|
||||
|
||||
if !shadowCopy {
|
||||
return top
|
||||
}
|
||||
|
||||
if timeoutInSeconds <= 60 {
|
||||
timeoutInSeconds = 60
|
||||
}
|
||||
ole.CoInitialize(0)
|
||||
defer ole.CoUninitialize()
|
||||
|
||||
@@ -418,7 +419,7 @@ func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
return top
|
||||
}
|
||||
|
||||
if !async.Wait(20) {
|
||||
if !async.Wait(timeoutInSeconds) {
|
||||
LOG_ERROR("VSS_GATHER", "Shadow copy creation failed: GatherWriterMetadata didn't finish properly")
|
||||
return top
|
||||
}
|
||||
@@ -458,7 +459,7 @@ func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
return top
|
||||
}
|
||||
|
||||
if !async.Wait(20) {
|
||||
if !async.Wait(timeoutInSeconds) {
|
||||
LOG_ERROR("VSS_PREPARE", "Shadow copy creation failed: PrepareForBackup didn't finish properly")
|
||||
return top
|
||||
}
|
||||
@@ -475,15 +476,13 @@ func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
return top
|
||||
}
|
||||
|
||||
if !async.Wait(60) {
|
||||
if !async.Wait(timeoutInSeconds) {
|
||||
LOG_ERROR("VSS_SNAPSHOT", "Shadow copy creation failed: DoSnapshotSet didn't finish properly")
|
||||
return top
|
||||
}
|
||||
async.Release()
|
||||
|
||||
|
||||
properties := SnapshotProperties {
|
||||
}
|
||||
properties := SnapshotProperties{}
|
||||
|
||||
ret = vssBackupComponent.GetSnapshotProperties(snapshotID, &properties)
|
||||
if ret != 0 {
|
||||
@@ -509,10 +508,10 @@ func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
|
||||
snapshotPath := uint16ArrayToString(properties.SnapshotDeviceObject)
|
||||
|
||||
preferencePath := GetDuplicacyPreferencePath(top)
|
||||
preferencePath := GetDuplicacyPreferencePath()
|
||||
shadowLink = preferencePath + "\\shadow"
|
||||
os.Remove(shadowLink)
|
||||
err = os.Symlink(snapshotPath + "\\", shadowLink)
|
||||
err = os.Symlink(snapshotPath+"\\", shadowLink)
|
||||
if err != nil {
|
||||
LOG_ERROR("VSS_SYMLINK", "Failed to create a symbolic link to the shadow copy just created: %v", err)
|
||||
return top
|
||||
@@ -521,5 +520,3 @@ func CreateShadowCopy(top string, shadowCopy bool) (shadowTop string) {
|
||||
return shadowLink + "\\" + top[2:]
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"time"
|
||||
"path"
|
||||
"strings"
|
||||
"strconv"
|
||||
"io/ioutil"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot represents a backup of the repository.
|
||||
@@ -47,29 +47,28 @@ type Snapshot struct {
|
||||
}
|
||||
|
||||
// CreateEmptySnapshot creates an empty snapshot.
|
||||
func CreateEmptySnapshot (id string) (snapshto *Snapshot) {
|
||||
func CreateEmptySnapshot(id string) (snapshto *Snapshot) {
|
||||
return &Snapshot{
|
||||
ID : id,
|
||||
Revision : 0,
|
||||
ID: id,
|
||||
Revision: 0,
|
||||
StartTime: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSnapshotFromDirectory creates a snapshot from the local directory 'top'. Only 'Files'
|
||||
// will be constructed, while 'ChunkHashes' and 'ChunkLengths' can only be populated after uploading.
|
||||
func CreateSnapshotFromDirectory(id string, top string) (snapshot *Snapshot, skippedDirectories []string,
|
||||
func CreateSnapshotFromDirectory(id string, top string, nobackupFile string) (snapshot *Snapshot, skippedDirectories []string,
|
||||
skippedFiles []string, err error) {
|
||||
|
||||
snapshot = &Snapshot {
|
||||
ID : id,
|
||||
snapshot = &Snapshot{
|
||||
ID: id,
|
||||
Revision: 0,
|
||||
StartTime: time.Now().Unix(),
|
||||
}
|
||||
|
||||
var patterns []string
|
||||
|
||||
preferencePath := GetDuplicacyPreferencePath(top)
|
||||
patternFile, err := ioutil.ReadFile(path.Join(preferencePath, "filters"))
|
||||
patternFile, err := ioutil.ReadFile(path.Join(GetDuplicacyPreferencePath(), "filters"))
|
||||
if err == nil {
|
||||
for _, pattern := range strings.Split(string(patternFile), "\n") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
@@ -77,17 +76,30 @@ func CreateSnapshotFromDirectory(id string, top string) (snapshot *Snapshot, ski
|
||||
continue
|
||||
}
|
||||
|
||||
if pattern[0] != '+' && pattern[0] != '-' {
|
||||
if pattern[0] == '#' {
|
||||
continue
|
||||
}
|
||||
|
||||
if IsUnspecifiedFilter(pattern) {
|
||||
pattern = "+" + pattern
|
||||
}
|
||||
|
||||
if pattern == "+" || pattern == "-" {
|
||||
if IsEmptyFilter(pattern) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pattern, "i:") || strings.HasPrefix(pattern, "e:") {
|
||||
valid, err := IsValidRegex(pattern[2:])
|
||||
if !valid || err != nil {
|
||||
LOG_ERROR("SNAPSHOT_FILTER", "Invalid regular expression encountered for filter: \"%s\", error: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
patterns = append(patterns, pattern)
|
||||
}
|
||||
|
||||
LOG_DEBUG("REGEX_DEBUG", "There are %d compiled regular expressions stored", len(RegexMap))
|
||||
|
||||
LOG_INFO("SNAPSHOT_FILTER", "Loaded %d include/exclude pattern(s)", len(patterns))
|
||||
|
||||
if IsTracing() {
|
||||
@@ -110,10 +122,10 @@ func CreateSnapshotFromDirectory(id string, top string) (snapshot *Snapshot, ski
|
||||
|
||||
for len(directories) > 0 {
|
||||
|
||||
directory := directories[len(directories) - 1]
|
||||
directories = directories[:len(directories) - 1]
|
||||
directory := directories[len(directories)-1]
|
||||
directories = directories[:len(directories)-1]
|
||||
snapshot.Files = append(snapshot.Files, directory)
|
||||
subdirectories, skipped, err := ListEntries(top, directory.Path, &snapshot.Files, patterns, snapshot.discardAttributes)
|
||||
subdirectories, skipped, err := ListEntries(top, directory.Path, &snapshot.Files, patterns, nobackupFile, snapshot.discardAttributes)
|
||||
if err != nil {
|
||||
LOG_WARN("LIST_FAILURE", "Failed to list subdirectory: %v", err)
|
||||
skippedDirectories = append(skippedDirectories, directory.Path)
|
||||
@@ -138,17 +150,111 @@ func CreateSnapshotFromDirectory(id string, top string) (snapshot *Snapshot, ski
|
||||
return snapshot, skippedDirectories, skippedFiles, nil
|
||||
}
|
||||
|
||||
// This is the struct used to save/load incomplete snapshots
|
||||
type IncompleteSnapshot struct {
|
||||
Files []*Entry
|
||||
ChunkHashes []string
|
||||
ChunkLengths []int
|
||||
}
|
||||
|
||||
// LoadIncompleteSnapshot loads the incomplete snapshot if it exists
|
||||
func LoadIncompleteSnapshot() (snapshot *Snapshot) {
|
||||
snapshotFile := path.Join(GetDuplicacyPreferencePath(), "incomplete")
|
||||
description, err := ioutil.ReadFile(snapshotFile)
|
||||
if err != nil {
|
||||
LOG_DEBUG("INCOMPLETE_LOCATE", "Failed to locate incomplete snapshot: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var incompleteSnapshot IncompleteSnapshot
|
||||
|
||||
err = json.Unmarshal(description, &incompleteSnapshot)
|
||||
if err != nil {
|
||||
LOG_DEBUG("INCOMPLETE_PARSE", "Failed to parse incomplete snapshot: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunkHashes []string
|
||||
for _, chunkHash := range incompleteSnapshot.ChunkHashes {
|
||||
hash, err := hex.DecodeString(chunkHash)
|
||||
if err != nil {
|
||||
LOG_DEBUG("INCOMPLETE_DECODE", "Failed to decode incomplete snapshot: %v", err)
|
||||
return nil
|
||||
}
|
||||
chunkHashes = append(chunkHashes, string(hash))
|
||||
}
|
||||
|
||||
snapshot = &Snapshot{
|
||||
Files: incompleteSnapshot.Files,
|
||||
ChunkHashes: chunkHashes,
|
||||
ChunkLengths: incompleteSnapshot.ChunkLengths,
|
||||
}
|
||||
LOG_INFO("INCOMPLETE_LOAD", "Incomplete snapshot loaded from %s", snapshotFile)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// SaveIncompleteSnapshot saves the incomplete snapshot under the preference directory
|
||||
func SaveIncompleteSnapshot(snapshot *Snapshot) {
|
||||
var files []*Entry
|
||||
for _, file := range snapshot.Files {
|
||||
// All unprocessed files will have a size of -1
|
||||
if file.Size >= 0 {
|
||||
file.Attributes = nil
|
||||
files = append(files, file)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
var chunkHashes []string
|
||||
for _, chunkHash := range snapshot.ChunkHashes {
|
||||
chunkHashes = append(chunkHashes, hex.EncodeToString([]byte(chunkHash)))
|
||||
}
|
||||
|
||||
incompleteSnapshot := IncompleteSnapshot{
|
||||
Files: files,
|
||||
ChunkHashes: chunkHashes,
|
||||
ChunkLengths: snapshot.ChunkLengths,
|
||||
}
|
||||
|
||||
description, err := json.MarshalIndent(incompleteSnapshot, "", " ")
|
||||
if err != nil {
|
||||
LOG_WARN("INCOMPLETE_ENCODE", "Failed to encode the incomplete snapshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
snapshotFile := path.Join(GetDuplicacyPreferencePath(), "incomplete")
|
||||
err = ioutil.WriteFile(snapshotFile, description, 0644)
|
||||
if err != nil {
|
||||
LOG_WARN("INCOMPLETE_WRITE", "Failed to save the incomplete snapshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
LOG_INFO("INCOMPLETE_SAVE", "Incomplete snapshot saved to %s", snapshotFile)
|
||||
}
|
||||
|
||||
func RemoveIncompleteSnapshot() {
|
||||
snapshotFile := path.Join(GetDuplicacyPreferencePath(), "incomplete")
|
||||
if stat, err := os.Stat(snapshotFile); err == nil && !stat.IsDir() {
|
||||
err = os.Remove(snapshotFile)
|
||||
if err != nil {
|
||||
LOG_INFO("INCOMPLETE_SAVE", "Failed to remove ncomplete snapshot: %v", err)
|
||||
} else {
|
||||
LOG_INFO("INCOMPLETE_SAVE", "Removed incomplete snapshot %s", snapshotFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSnapshotFromDescription creates a snapshot from json decription.
|
||||
func CreateSnapshotFromDescription(description []byte) (snapshot *Snapshot, err error) {
|
||||
|
||||
var root map[string] interface{}
|
||||
var root map[string]interface{}
|
||||
|
||||
err = json.Unmarshal(description, &root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot = &Snapshot {}
|
||||
snapshot = &Snapshot{}
|
||||
|
||||
if value, ok := root["id"]; !ok {
|
||||
return nil, fmt.Errorf("No id is specified in the snapshot")
|
||||
@@ -202,7 +308,7 @@ func CreateSnapshotFromDescription(description []byte) (snapshot *Snapshot, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, sequenceType := range []string { "files", "chunks", "lengths" } {
|
||||
for _, sequenceType := range []string{"files", "chunks", "lengths"} {
|
||||
if value, ok := root[sequenceType]; !ok {
|
||||
return nil, fmt.Errorf("No %s are specified in the snapshot", sequenceType)
|
||||
} else if _, ok = value.([]interface{}); !ok {
|
||||
@@ -230,7 +336,7 @@ func CreateSnapshotFromDescription(description []byte) (snapshot *Snapshot, err
|
||||
// LoadChunks construct 'ChunkHashes' from the json description.
|
||||
func (snapshot *Snapshot) LoadChunks(description []byte) (err error) {
|
||||
|
||||
var root [] interface {}
|
||||
var root []interface{}
|
||||
err = json.Unmarshal(description, &root)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -251,13 +357,18 @@ func (snapshot *Snapshot) LoadChunks(description []byte) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearChunks removes loaded chunks from memory
|
||||
func (snapshot *Snapshot) ClearChunks() {
|
||||
snapshot.ChunkHashes = nil
|
||||
}
|
||||
|
||||
// LoadLengths construct 'ChunkLengths' from the json description.
|
||||
func (snapshot *Snapshot) LoadLengths(description []byte) (err error) {
|
||||
return json.Unmarshal(description, &snapshot.ChunkLengths)
|
||||
}
|
||||
|
||||
// MarshalJSON creates a json representation of the snapshot.
|
||||
func (snapshot *Snapshot) MarshalJSON() ([] byte, error) {
|
||||
func (snapshot *Snapshot) MarshalJSON() ([]byte, error) {
|
||||
|
||||
object := make(map[string]interface{})
|
||||
|
||||
@@ -280,7 +391,7 @@ func (snapshot *Snapshot) MarshalJSON() ([] byte, error) {
|
||||
}
|
||||
|
||||
// MarshalSequence creates a json represetion for the specified chunk sequence.
|
||||
func (snapshot *Snapshot) MarshalSequence(sequenceType string) ([] byte, error) {
|
||||
func (snapshot *Snapshot) MarshalSequence(sequenceType string) ([]byte, error) {
|
||||
|
||||
if sequenceType == "files" {
|
||||
return json.Marshal(snapshot.Files)
|
||||
@@ -292,7 +403,7 @@ func (snapshot *Snapshot) MarshalSequence(sequenceType string) ([] byte, error)
|
||||
}
|
||||
|
||||
// SetSequence assign a chunk sequence to the specified field.
|
||||
func (snapshot *Snapshot) SetSequence(sequenceType string, sequence [] string) {
|
||||
func (snapshot *Snapshot) SetSequence(sequenceType string, sequence []string) {
|
||||
if sequenceType == "files" {
|
||||
snapshot.FileSequence = sequence
|
||||
} else if sequenceType == "chunks" {
|
||||
@@ -303,7 +414,7 @@ func (snapshot *Snapshot) SetSequence(sequenceType string, sequence [] string) {
|
||||
}
|
||||
|
||||
// encodeSequence turns a sequence of binary hashes into a sequence of hex hashes.
|
||||
func encodeSequence(sequence[] string) ([] string) {
|
||||
func encodeSequence(sequence []string) []string {
|
||||
|
||||
sequenceInHex := make([]string, len(sequence))
|
||||
|
||||
@@ -313,5 +424,3 @@ func encodeSequence(sequence[] string) ([] string) {
|
||||
|
||||
return sequenceInHex
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,24 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"os"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
func createDummySnapshot(snapshotID string, revision int, endTime int64) * Snapshot {
|
||||
return &Snapshot {
|
||||
func createDummySnapshot(snapshotID string, revision int, endTime int64) *Snapshot {
|
||||
return &Snapshot{
|
||||
ID: snapshotID,
|
||||
Revision: revision,
|
||||
EndTime: endTime,
|
||||
@@ -31,15 +32,15 @@ func TestIsDeletable(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
day := int64(3600 * 24)
|
||||
|
||||
allSnapshots := make(map[string][] *Snapshot)
|
||||
allSnapshots["host1"] = append([]*Snapshot{}, createDummySnapshot("host1", 1, now - 2 * day))
|
||||
allSnapshots["host2"] = append([]*Snapshot{}, createDummySnapshot("host2", 1, now - 2 * day))
|
||||
allSnapshots["host1"] = append(allSnapshots["host1"], createDummySnapshot("host1", 2, now - 1 * day))
|
||||
allSnapshots["host2"] = append(allSnapshots["host2"], createDummySnapshot("host2", 2, now - 1 * day))
|
||||
allSnapshots := make(map[string][]*Snapshot)
|
||||
allSnapshots["host1"] = append([]*Snapshot{}, createDummySnapshot("host1", 1, now-2*day))
|
||||
allSnapshots["host2"] = append([]*Snapshot{}, createDummySnapshot("host2", 1, now-2*day))
|
||||
allSnapshots["host1"] = append(allSnapshots["host1"], createDummySnapshot("host1", 2, now-1*day))
|
||||
allSnapshots["host2"] = append(allSnapshots["host2"], createDummySnapshot("host2", 2, now-1*day))
|
||||
|
||||
collection := & FossilCollection {
|
||||
collection := &FossilCollection{
|
||||
EndTime: now - day - 3600,
|
||||
LastRevisions: make(map[string] int),
|
||||
LastRevisions: make(map[string]int),
|
||||
}
|
||||
|
||||
collection.LastRevisions["host1"] = 1
|
||||
@@ -51,21 +52,21 @@ func TestIsDeletable(t *testing.T) {
|
||||
}
|
||||
|
||||
collection.LastRevisions["host3"] = 1
|
||||
allSnapshots["host3"] = append([]*Snapshot{}, createDummySnapshot("host3", 1, now - 2 * day))
|
||||
allSnapshots["host3"] = append([]*Snapshot{}, createDummySnapshot("host3", 1, now-2*day))
|
||||
|
||||
isDeletable, newSnapshots = collection.IsDeletable(true, nil, allSnapshots)
|
||||
if isDeletable {
|
||||
t.Errorf("Scenario 2: should not be deletable")
|
||||
}
|
||||
|
||||
allSnapshots["host3"] = append(allSnapshots["host3"], createDummySnapshot("host3", 2, now - day))
|
||||
allSnapshots["host3"] = append(allSnapshots["host3"], createDummySnapshot("host3", 2, now-day))
|
||||
isDeletable, newSnapshots = collection.IsDeletable(true, nil, allSnapshots)
|
||||
if !isDeletable || len(newSnapshots) != 3 {
|
||||
t.Errorf("Scenario 3: should be deletable, 3 new snapshots")
|
||||
}
|
||||
|
||||
collection.LastRevisions["host4"] = 1
|
||||
allSnapshots["host4"] = append([]*Snapshot{}, createDummySnapshot("host4", 1, now - 8 * day))
|
||||
allSnapshots["host4"] = append([]*Snapshot{}, createDummySnapshot("host4", 1, now-8*day))
|
||||
|
||||
isDeletable, newSnapshots = collection.IsDeletable(true, nil, allSnapshots)
|
||||
if !isDeletable || len(newSnapshots) != 3 {
|
||||
@@ -73,17 +74,17 @@ func TestIsDeletable(t *testing.T) {
|
||||
}
|
||||
|
||||
collection.LastRevisions["repository1@host5"] = 1
|
||||
allSnapshots["repository1@host5"] = append([]*Snapshot{}, createDummySnapshot("repository1@host5", 1, now - 3 * day))
|
||||
allSnapshots["repository1@host5"] = append([]*Snapshot{}, createDummySnapshot("repository1@host5", 1, now-3*day))
|
||||
|
||||
collection.LastRevisions["repository2@host5"] = 1
|
||||
allSnapshots["repository2@host5"] = append([]*Snapshot{}, createDummySnapshot("repository2@host5", 1, now - 2 * day))
|
||||
allSnapshots["repository2@host5"] = append([]*Snapshot{}, createDummySnapshot("repository2@host5", 1, now-2*day))
|
||||
|
||||
isDeletable, newSnapshots = collection.IsDeletable(true, nil, allSnapshots)
|
||||
if isDeletable {
|
||||
t.Errorf("Scenario 5: should not be deletable")
|
||||
}
|
||||
|
||||
allSnapshots["repository1@host5"] = append(allSnapshots["repository1@host5"], createDummySnapshot("repository1@host5", 2, now - day))
|
||||
allSnapshots["repository1@host5"] = append(allSnapshots["repository1@host5"], createDummySnapshot("repository1@host5", 2, now-day))
|
||||
isDeletable, newSnapshots = collection.IsDeletable(true, nil, allSnapshots)
|
||||
if !isDeletable || len(newSnapshots) != 4 {
|
||||
t.Errorf("Scenario 6: should be deletable, 4 new snapshots")
|
||||
@@ -95,18 +96,21 @@ func createTestSnapshotManager(testDir string) *SnapshotManager {
|
||||
os.RemoveAll(testDir)
|
||||
os.MkdirAll(testDir, 0700)
|
||||
|
||||
storage, _ := CreateFileStorage(testDir, 1)
|
||||
storage, _ := CreateFileStorage(testDir, false, 1)
|
||||
storage.CreateDirectory(0, "chunks")
|
||||
storage.CreateDirectory(0, "snapshots")
|
||||
config := CreateConfig()
|
||||
snapshotManager := CreateSnapshotManager(config, storage)
|
||||
|
||||
cacheDir := path.Join(testDir, "cache")
|
||||
snapshotCache, _ := CreateFileStorage(cacheDir, 1)
|
||||
snapshotCache, _ := CreateFileStorage(cacheDir, false, 1)
|
||||
snapshotCache.CreateDirectory(0, "chunks")
|
||||
snapshotCache.CreateDirectory(0, "snapshots")
|
||||
|
||||
snapshotManager.snapshotCache = snapshotCache
|
||||
|
||||
SetDuplicacyPreferencePath(testDir + "/.duplicacy")
|
||||
|
||||
return snapshotManager
|
||||
}
|
||||
|
||||
@@ -140,39 +144,49 @@ func uploadRandomChunk(manager *SnapshotManager, chunkSize int) string {
|
||||
return uploadTestChunk(manager, content)
|
||||
}
|
||||
|
||||
func createTestSnapshot(manager *SnapshotManager, snapshotID string, revision int, startTime int64, endTime int64, chunkHashes []string) {
|
||||
func uploadRandomChunks(manager *SnapshotManager, chunkSize int, numberOfChunks int) []string {
|
||||
chunkList := make([]string, 0)
|
||||
for i := 0; i < numberOfChunks; i++ {
|
||||
chunkHash := uploadRandomChunk(manager, chunkSize)
|
||||
chunkList = append(chunkList, chunkHash)
|
||||
}
|
||||
return chunkList
|
||||
}
|
||||
|
||||
snapshot := &Snapshot {
|
||||
func createTestSnapshot(manager *SnapshotManager, snapshotID string, revision int, startTime int64, endTime int64, chunkHashes []string, tag string) {
|
||||
|
||||
snapshot := &Snapshot{
|
||||
ID: snapshotID,
|
||||
Revision: revision,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ChunkHashes: chunkHashes,
|
||||
Tag: tag,
|
||||
}
|
||||
|
||||
var chunkHashesInHex [] string
|
||||
var chunkHashesInHex []string
|
||||
for _, chunkHash := range chunkHashes {
|
||||
chunkHashesInHex = append(chunkHashesInHex, hex.EncodeToString([]byte(chunkHash)))
|
||||
}
|
||||
|
||||
sequence, _ := json.Marshal(chunkHashesInHex)
|
||||
snapshot.ChunkSequence = []string { uploadTestChunk(manager, sequence) }
|
||||
snapshot.ChunkSequence = []string{uploadTestChunk(manager, sequence)}
|
||||
|
||||
description, _ := snapshot.MarshalJSON()
|
||||
path := fmt.Sprintf("snapshots/%s/%d", snapshotID, snapshot.Revision)
|
||||
manager.storage.CreateDirectory(0, "snapshots/" + snapshotID)
|
||||
manager.storage.CreateDirectory(0, "snapshots/"+snapshotID)
|
||||
manager.UploadFile(path, path, description)
|
||||
}
|
||||
|
||||
func checkTestSnapshots(manager *SnapshotManager, expectedSnapshots int, expectedFossils int) {
|
||||
|
||||
var snapshotIDs [] string
|
||||
var snapshotIDs []string
|
||||
var err error
|
||||
|
||||
chunks := make(map[string]bool)
|
||||
files, _ := manager.ListAllFiles(manager.storage, "chunks/")
|
||||
for _, file := range files {
|
||||
if file[len(file) - 1] == '/' {
|
||||
if file[len(file)-1] == '/' {
|
||||
continue
|
||||
}
|
||||
chunk := strings.Replace(file, "/", "", -1)
|
||||
@@ -181,7 +195,7 @@ func checkTestSnapshots(manager *SnapshotManager, expectedSnapshots int, expecte
|
||||
|
||||
snapshotIDs, err = manager.ListSnapshotIDs()
|
||||
if err != nil {
|
||||
LOG_ERROR("SNAPSHOT_LIST", "Failed to list all snpashots: %v", err)
|
||||
LOG_ERROR("SNAPSHOT_LIST", "Failed to list all snapshots: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -199,7 +213,7 @@ func checkTestSnapshots(manager *SnapshotManager, expectedSnapshots int, expecte
|
||||
snapshot := manager.DownloadSnapshot(snapshotID, revision)
|
||||
numberOfSnapshots++
|
||||
|
||||
for _, chunk := range manager.GetSnapshotChunks(snapshot) {
|
||||
for _, chunk := range manager.GetSnapshotChunks(snapshot, false) {
|
||||
chunks[chunk] = true
|
||||
}
|
||||
}
|
||||
@@ -222,7 +236,7 @@ func checkTestSnapshots(manager *SnapshotManager, expectedSnapshots int, expecte
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRepositoryPrune(t *testing.T) {
|
||||
func TestPruneSingleRepository(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
@@ -238,34 +252,35 @@ func TestSingleRepositoryPrune(t *testing.T) {
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 1 snapshot")
|
||||
createTestSnapshot(snapshotManager, "repository1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash1, chunkHash2 })
|
||||
checkTestSnapshots(snapshotManager, 1, 2)
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "repository1", 1, now-4*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "repository1", 2, now-4*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "repository1", 2, now - 2 * day - 3600, now - 2 * day - 60, []string { chunkHash2, chunkHash3 })
|
||||
createTestSnapshot(snapshotManager, "repository1", 3, now - 1 * day - 3600, now - 1 * day - 60, []string { chunkHash3, chunkHash4 })
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
createTestSnapshot(snapshotManager, "repository1", 3, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
createTestSnapshot(snapshotManager, "repository1", 4, now-1*day-3600, now-1*day-60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 4, 0)
|
||||
|
||||
t.Logf("Removing snapshot repository1 revision 1 with --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "repository1", "repository1", []int{1}, []string{}, []string{}, false, true, []string{}, false, false, false)
|
||||
t.Logf("Removing snapshot repository1 revisions 1 and 2 with --exclusive")
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{1, 2}, []string{}, []string{}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
|
||||
t.Logf("Removing snapshot repository1 revision 2 without --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "repository1", "repository1", []int{2}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
t.Logf("Removing snapshot repository1 revision 3 without --exclusive")
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{3}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 1, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "repository1", 4, now + 1 * day - 3600 , now + 1 * day, []string { chunkHash4, chunkHash5 })
|
||||
createTestSnapshot(snapshotManager, "repository1", 5, now+1*day-3600, now+1*day, []string{chunkHash4, chunkHash5}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "repository1", "repository1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
}
|
||||
|
||||
func TestSingleHostPrune(t *testing.T) {
|
||||
func TestPruneSingleHost(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
@@ -282,31 +297,31 @@ func TestSingleHostPrune(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 3 snapshots")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash1, chunkHash2 })
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now - 2 * day - 3600, now - 2 * day - 60, []string { chunkHash2, chunkHash3 })
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash3, chunkHash4 })
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 revision 1 without --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- no fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 2, now + 1 * day - 3600 , now + 1 * day, []string { chunkHash4, chunkHash5 })
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 2, now+1*day-3600, now+1*day, []string{chunkHash4, chunkHash5}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
|
||||
}
|
||||
|
||||
func TestMultipleHostPrune(t *testing.T) {
|
||||
func TestPruneMultipleHost(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
@@ -323,35 +338,35 @@ func TestMultipleHostPrune(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 3 snapshot")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash1, chunkHash2 })
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now - 2 * day - 3600, now - 2 * day - 60, []string { chunkHash2, chunkHash3 })
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash3, chunkHash4 })
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 1, now-3*day-3600, now-3*day-60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 revision 1 without --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- no fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 2, now + 1 * day - 3600 , now + 1 * day, []string {chunkHash4, chunkHash5})
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 2, now+1*day-3600, now+1*day, []string{chunkHash4, chunkHash5}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- no fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash6 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now + 1 * day - 3600 , now + 1 * day, []string {chunkHash5, chunkHash6})
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now+1*day-3600, now+1*day, []string{chunkHash5, chunkHash6}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 4, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 4, 0)
|
||||
}
|
||||
|
||||
@@ -371,25 +386,25 @@ func TestPruneAndResurrect(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash1, chunkHash2})
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now - 2 * day - 3600, now - 2 * day - 60, []string { chunkHash2, chunkHash3})
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 revision 1 without --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 1, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash4 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 4, now + 1 * day - 3600 , now + 1 * day, []string { chunkHash4, chunkHash1})
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 4, now+1*day-3600, now+1*day, []string{chunkHash4, chunkHash1}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- one fossil will be resurrected")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
}
|
||||
|
||||
func TestInactiveHostPrune(t *testing.T) {
|
||||
func TestPruneWithInactiveHost(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
@@ -406,31 +421,31 @@ func TestInactiveHostPrune(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 3 snapshot")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now - 3 * day - 3600, now - 3 * day - 60, []string { chunkHash1, chunkHash2} )
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now - 2 * day - 3600, now - 2 * day - 60, []string { chunkHash2, chunkHash3} )
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
// Host2 is inactive
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 1, now - 7 * day - 3600, now - 7 * day - 60, []string { chunkHash3, chunkHash4} )
|
||||
createTestSnapshot(snapshotManager, "vm2@host2", 1, now-7*day-3600, now-7*day-60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 revision 1")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- no fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
t.Logf("Creating 1 snapshot")
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now + 1 * day - 3600 , now + 1 * day, []string { chunkHash4, chunkHash5} )
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now+1*day-3600, now+1*day, []string{chunkHash4, chunkHash5}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
|
||||
t.Logf("Prune without removing any snapshots -- fossils will be deleted")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
}
|
||||
|
||||
func TestRetentionPolicy(t *testing.T) {
|
||||
func TestPruneWithRetentionPolicy(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
@@ -439,7 +454,7 @@ func TestRetentionPolicy(t *testing.T) {
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
var chunkHashes [] string
|
||||
var chunkHashes []string
|
||||
for i := 0; i < 30; i++ {
|
||||
chunkHashes = append(chunkHashes, uploadRandomChunk(snapshotManager, chunkSize))
|
||||
}
|
||||
@@ -448,20 +463,225 @@ func TestRetentionPolicy(t *testing.T) {
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 30 snapshots")
|
||||
for i := 0; i < 30; i++ {
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", i + 1, now - int64(30 - i) * day - 3600, now - int64(30 - i) * day - 60, []string { chunkHashes[i] })
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", i+1, now-int64(30-i)*day-3600, now-int64(30-i)*day-60, []string{chunkHashes[i]}, "tag")
|
||||
}
|
||||
|
||||
checkTestSnapshots(snapshotManager, 30, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 0:20 with --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{"0:20"}, false, true, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{"0:20"}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 19, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 -k 0:20 with --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{"0:20"}, false, true, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{"0:20"}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 19, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 -k 3:14 -k 2:7 with --exclusive")
|
||||
snapshotManager.PruneSnapshots(testDir, "vm1@host1", "vm1@host1", []int{}, []string{}, []string{"3:14", "2:7"}, false, true, []string{}, false, false, false)
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{"3:14", "2:7"}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 12, 0)
|
||||
}
|
||||
|
||||
func TestPruneWithRetentionPolicyAndTag(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "snapshot_test")
|
||||
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
var chunkHashes []string
|
||||
for i := 0; i < 30; i++ {
|
||||
chunkHashes = append(chunkHashes, uploadRandomChunk(snapshotManager, chunkSize))
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 30 snapshots")
|
||||
for i := 0; i < 30; i++ {
|
||||
tag := "auto"
|
||||
if i % 3 == 0 {
|
||||
tag = "manual"
|
||||
}
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", i+1, now-int64(30-i)*day-3600, now-int64(30-i)*day-60, []string{chunkHashes[i]}, tag)
|
||||
}
|
||||
|
||||
checkTestSnapshots(snapshotManager, 30, 0)
|
||||
|
||||
t.Logf("Removing snapshot vm1@host1 0:20 with --exclusive and --tag manual")
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{"manual"}, []string{"0:7"}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 22, 0)
|
||||
}
|
||||
|
||||
// Test that an unreferenced fossil shouldn't be removed as it may be the result of another prune job in-progress.
|
||||
func TestPruneWithFossils(t *testing.T) {
|
||||
setTestingT(t)
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "snapshot_test")
|
||||
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
chunkHash1 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash2 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash3 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
// Create an unreferenced fossil
|
||||
snapshotManager.storage.UploadFile(0, "chunks/113b6a2350dcfd836829c47304dd330fa6b58b93dd7ac696c6b7b913e6868662.fsl", []byte("this is a test fossil"))
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 1)
|
||||
|
||||
t.Logf("Prune without removing any snapshots but with --exhaustive")
|
||||
// The unreferenced fossil shouldn't be removed
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, true, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 1)
|
||||
|
||||
t.Logf("Prune without removing any snapshots but with --exclusive")
|
||||
// Now the unreferenced fossil should be removed
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, true, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
}
|
||||
|
||||
func TestPruneMultipleThread(t *testing.T) {
|
||||
|
||||
setTestingT(t)
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "snapshot_test")
|
||||
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
numberOfChunks := 256
|
||||
numberOfThreads := 4
|
||||
|
||||
chunkList1 := uploadRandomChunks(snapshotManager, chunkSize, numberOfChunks)
|
||||
chunkList2 := uploadRandomChunks(snapshotManager, chunkSize, numberOfChunks)
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "repository1", 1, now-4*day-3600, now-3*day-60, chunkList1, "tag")
|
||||
createTestSnapshot(snapshotManager, "repository1", 2, now-3*day-3600, now-2*day-60, chunkList2, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
|
||||
t.Logf("Removing snapshot revisions 1 with --exclusive")
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{1}, []string{}, []string{}, false, true, []string{}, false, false, false, numberOfThreads)
|
||||
checkTestSnapshots(snapshotManager, 1, 0)
|
||||
|
||||
t.Logf("Creating 1 more snapshot")
|
||||
chunkList3 := uploadRandomChunks(snapshotManager, chunkSize, numberOfChunks)
|
||||
createTestSnapshot(snapshotManager, "repository1", 3, now-2*day-3600, now-1*day-60, chunkList3, "tag")
|
||||
|
||||
t.Logf("Removing snapshot repository1 revision 2 without --exclusive")
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{2}, []string{}, []string{}, false, false, []string{}, false, false, false, numberOfThreads)
|
||||
|
||||
t.Logf("Prune without removing any snapshots but with --exclusive")
|
||||
snapshotManager.PruneSnapshots("repository1", "repository1", []int{}, []string{}, []string{}, false, true, []string{}, false, false, false, numberOfThreads)
|
||||
checkTestSnapshots(snapshotManager, 1, 0)
|
||||
}
|
||||
|
||||
// A snapshot not seen by a fossil collection should always be consider a new snapshot in the fossil deletion step
|
||||
func TestPruneNewSnapshots(t *testing.T) {
|
||||
setTestingT(t)
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "snapshot_test")
|
||||
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
chunkHash1 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash2 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash3 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash4 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 3 snapshots")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 1, now-2*day-3600, now-2*day-60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
|
||||
t.Logf("Prune snapshot 1")
|
||||
// chunkHash1 should be marked as fossil
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
// Create another snapshot of vm1 that brings back chunkHash1
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now-0*day-3600, now-0*day-60, []string{chunkHash1, chunkHash3}, "tag")
|
||||
// Create another snapshot of vm2 so the fossil collection will be processed by next prune
|
||||
createTestSnapshot(snapshotManager, "vm2@host1", 2, now + 3600, now + 3600 * 2, []string{chunkHash4, chunkHash5}, "tag")
|
||||
|
||||
// Now chunkHash1 wil be resurrected
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 4, 0)
|
||||
snapshotManager.CheckSnapshots("vm1@host1", []int{2, 3}, "", false, false, false, false, false);
|
||||
}
|
||||
|
||||
// A fossil collection left by an aborted prune should be ignored if any supposedly deleted snapshot exists
|
||||
func TestPruneGhostSnapshots(t *testing.T) {
|
||||
setTestingT(t)
|
||||
|
||||
EnableStackTrace()
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "snapshot_test")
|
||||
|
||||
snapshotManager := createTestSnapshotManager(testDir)
|
||||
|
||||
chunkSize := 1024
|
||||
chunkHash1 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash2 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
chunkHash3 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
|
||||
now := time.Now().Unix()
|
||||
day := int64(24 * 3600)
|
||||
t.Logf("Creating 2 snapshots")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 1, now-3*day-3600, now-3*day-60, []string{chunkHash1, chunkHash2}, "tag")
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 2, now-2*day-3600, now-2*day-60, []string{chunkHash2, chunkHash3}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 2, 0)
|
||||
|
||||
snapshot1, err := ioutil.ReadFile(path.Join(testDir, "snapshots", "vm1@host1", "1"))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read snapshot file: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Prune snapshot 1")
|
||||
// chunkHash1 should be marked as fossil
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 1, 2)
|
||||
|
||||
// Recover the snapshot file for revision 1; this is to simulate a scenario where prune may encounter a network error after
|
||||
// leaving the fossil collection but before deleting any snapshots.
|
||||
err = ioutil.WriteFile(path.Join(testDir, "snapshots", "vm1@host1", "1"), snapshot1, 0644)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to write snapshot file: %v", err)
|
||||
}
|
||||
|
||||
// Create another snapshot of vm1 so the fossil collection becomes eligible for processing.
|
||||
chunkHash4 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 3, now - day - 3600, now - day - 60, []string{chunkHash3, chunkHash4}, "tag")
|
||||
|
||||
// Run the prune again but the fossil collection should be igored, since revision 1 still exists
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
snapshotManager.CheckSnapshots("vm1@host1", []int{1, 2, 3}, "", false, false, false, true /*searchFossils*/, false);
|
||||
|
||||
// Prune snapshot 1 again
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{1}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 2, 2)
|
||||
|
||||
// Create another snapshot
|
||||
chunkHash5 := uploadRandomChunk(snapshotManager, chunkSize)
|
||||
createTestSnapshot(snapshotManager, "vm1@host1", 4, now + 3600, now + 3600 * 2, []string{chunkHash5, chunkHash5}, "tag")
|
||||
checkTestSnapshots(snapshotManager, 3, 2)
|
||||
|
||||
// Run the prune again and this time the fossil collection will be processed and the fossils removed
|
||||
snapshotManager.PruneSnapshots("vm1@host1", "vm1@host1", []int{}, []string{}, []string{}, false, false, []string{}, false, false, false, 1)
|
||||
checkTestSnapshots(snapshotManager, 3, 0)
|
||||
snapshotManager.CheckSnapshots("vm1@host1", []int{2, 3, 4}, "", false, false, false, false, false);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"strconv"
|
||||
"os"
|
||||
"net"
|
||||
"path"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
ListFiles(threadIndex int, dir string) (files []string, size []int64, err error)
|
||||
// ListFiles return the list of files and subdirectories under 'dir'. A subdirectories returned must have a trailing '/', with
|
||||
// a size of 0. If 'dir' is 'snapshots', only subdirectories will be returned. If 'dir' is 'snapshots/repository_id', then only
|
||||
// files will be returned. If 'dir' is 'chunks', the implementation can return the list either recusively or non-recusively.
|
||||
ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error)
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
DeleteFile(threadIndex int, filePath string) (err error)
|
||||
@@ -45,18 +48,21 @@ type Storage interface {
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
UploadFile(threadIndex int, filePath string, content []byte) (err error)
|
||||
|
||||
// SetNestingLevels sets up the chunk nesting structure.
|
||||
SetNestingLevels(config *Config)
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
IsCacheNeeded() (bool)
|
||||
IsCacheNeeded() bool
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
IsMoveFileImplemented() (bool)
|
||||
IsMoveFileImplemented() bool
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
IsStrongConsistent() (bool)
|
||||
IsStrongConsistent() bool
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
IsFastListing() (bool)
|
||||
IsFastListing() bool
|
||||
|
||||
// Enable the test mode.
|
||||
EnableTestMode()
|
||||
@@ -65,25 +71,104 @@ type Storage interface {
|
||||
SetRateLimits(downloadRateLimit int, uploadRateLimit int)
|
||||
}
|
||||
|
||||
type RateLimitedStorage struct {
|
||||
DownloadRateLimit int
|
||||
UploadRateLimit int
|
||||
// StorageBase is the base struct from which all storages are derived from
|
||||
type StorageBase struct {
|
||||
DownloadRateLimit int // Maximum download rate (bytes/seconds)
|
||||
UploadRateLimit int // Maximum upload reate (bytes/seconds)
|
||||
|
||||
DerivedStorage Storage // Used as the pointer to the derived storage class
|
||||
|
||||
readLevels []int // At which nesting level to find the chunk with the given id
|
||||
writeLevel int // Store the uploaded chunk to this level
|
||||
}
|
||||
|
||||
func (storage *RateLimitedStorage) SetRateLimits(downloadRateLimit int, uploadRateLimit int) {
|
||||
// SetRateLimits sets the maximum download and upload rates
|
||||
func (storage *StorageBase) SetRateLimits(downloadRateLimit int, uploadRateLimit int) {
|
||||
storage.DownloadRateLimit = downloadRateLimit
|
||||
storage.UploadRateLimit = uploadRateLimit
|
||||
}
|
||||
|
||||
func checkHostKey(repository string, hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
// SetDefaultNestingLevels sets the default read and write levels. This is usually called by
|
||||
// derived storages to set the levels with old values so that storages initialied by ealier versions
|
||||
// will continue to work.
|
||||
func (storage *StorageBase) SetDefaultNestingLevels(readLevels []int, writeLevel int) {
|
||||
storage.readLevels = readLevels
|
||||
storage.writeLevel = writeLevel
|
||||
}
|
||||
|
||||
if len(repository) == 0 {
|
||||
return nil
|
||||
// SetNestingLevels sets the new read and write levels (normally both at 1) if the 'config' file has
|
||||
// the 'fixed-nesting' key, or if a file named 'nesting' exists on the storage.
|
||||
func (storage *StorageBase) SetNestingLevels(config *Config) {
|
||||
|
||||
// 'FixedNesting' is true only for the 'config' file with the new format (2.0.10+)
|
||||
if config.FixedNesting {
|
||||
|
||||
storage.readLevels = nil
|
||||
|
||||
// Check if the 'nesting' file exist
|
||||
exist, _, _, err := storage.DerivedStorage.GetFileInfo(0, "nesting")
|
||||
if err == nil && exist {
|
||||
nestingFile := CreateChunk(CreateConfig(), true)
|
||||
if storage.DerivedStorage.DownloadFile(0, "nesting", nestingFile) == nil {
|
||||
var nesting struct {
|
||||
ReadLevels []int `json:"read-levels"`
|
||||
WriteLevel int `json:"write-level"`
|
||||
}
|
||||
if json.Unmarshal(nestingFile.GetBytes(), &nesting) == nil {
|
||||
storage.readLevels = nesting.ReadLevels
|
||||
storage.writeLevel = nesting.WriteLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferencePath := GetDuplicacyPreferencePath(repository)
|
||||
if len(storage.readLevels) == 0 {
|
||||
storage.readLevels = []int{1}
|
||||
storage.writeLevel = 1
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DEBUG("STORAGE_NESTING", "Chunk read levels: %v, write level: %d", storage.readLevels, storage.writeLevel)
|
||||
for _, level := range storage.readLevels {
|
||||
if storage.writeLevel == level {
|
||||
return
|
||||
}
|
||||
}
|
||||
LOG_ERROR("STORAGE_NESTING", "The write level %d isn't in the read levels %v", storage.readLevels, storage.writeLevel)
|
||||
}
|
||||
|
||||
// FindChunk finds the chunk with the specified id at the levels one by one as specified by 'readLevels'.
|
||||
func (storage *StorageBase) FindChunk(threadIndex int, chunkID string, isFossil bool) (filePath string, exist bool, size int64, err error) {
|
||||
chunkPaths := make([]string, 0)
|
||||
for _, level := range storage.readLevels {
|
||||
chunkPath := "chunks/"
|
||||
for i := 0; i < level; i++ {
|
||||
chunkPath += chunkID[2*i:2*i+2] + "/"
|
||||
}
|
||||
chunkPath += chunkID[2*level:]
|
||||
if isFossil {
|
||||
chunkPath += ".fsl"
|
||||
}
|
||||
exist, _, size, err = storage.DerivedStorage.GetFileInfo(threadIndex, chunkPath)
|
||||
if err == nil && exist {
|
||||
return chunkPath, exist, size, err
|
||||
}
|
||||
chunkPaths = append(chunkPaths, chunkPath)
|
||||
}
|
||||
for i, level := range storage.readLevels {
|
||||
if storage.writeLevel == level {
|
||||
return chunkPaths[i], false, 0, nil
|
||||
}
|
||||
}
|
||||
return "", false, 0, fmt.Errorf("Invalid chunk nesting setup")
|
||||
}
|
||||
|
||||
func checkHostKey(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
|
||||
if preferencePath == "" {
|
||||
return fmt.Errorf("Can't verify SSH host since the preference path is not set")
|
||||
}
|
||||
hostFile := path.Join(preferencePath, "known_hosts")
|
||||
file, err := os.OpenFile(hostFile, os.O_RDWR | os.O_CREATE, 0600)
|
||||
file, err := os.OpenFile(hostFile, os.O_RDWR|os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,7 +185,7 @@ func checkHostKey(repository string, hostname string, remote net.Addr, key ssh.P
|
||||
keyString = strings.Replace(keyString, "\n", "", -1)
|
||||
remoteAddress := remote.String()
|
||||
if strings.HasSuffix(remoteAddress, ":22") {
|
||||
remoteAddress = remoteAddress[:len(remoteAddress) - len(":22")]
|
||||
remoteAddress = remoteAddress[:len(remoteAddress)-len(":22")]
|
||||
}
|
||||
|
||||
for i, line := range strings.Split(string(content), "\n") {
|
||||
@@ -126,11 +211,12 @@ func checkHostKey(repository string, hostname string, remote net.Addr, key ssh.P
|
||||
}
|
||||
|
||||
// CreateStorage creates a storage object based on the provide storage URL.
|
||||
func CreateStorage(repository string, preference Preference, resetPassword bool, threads int) (storage Storage) {
|
||||
func CreateStorage(preference Preference, resetPassword bool, threads int) (storage Storage) {
|
||||
|
||||
storageURL := preference.StorageURL
|
||||
|
||||
isFileStorage := false
|
||||
isCacheNeeded := false
|
||||
|
||||
if strings.HasPrefix(storageURL, "/") {
|
||||
isFileStorage = true
|
||||
@@ -144,11 +230,12 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
|
||||
if !isFileStorage && strings.HasPrefix(storageURL, `\\`) {
|
||||
isFileStorage = true
|
||||
isCacheNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
if isFileStorage {
|
||||
fileStorage, err := CreateFileStorage(storageURL, threads)
|
||||
fileStorage, err := CreateFileStorage(storageURL, isCacheNeeded, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the file storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
@@ -156,7 +243,25 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
return fileStorage
|
||||
}
|
||||
|
||||
urlRegex := regexp.MustCompile(`^(\w+)://([\w\-]+@)?([^/]+)(/(.+))?`)
|
||||
if strings.HasPrefix(storageURL, "flat://") {
|
||||
fileStorage, err := CreateFileStorage(storageURL[7:], false, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the file storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
return fileStorage
|
||||
}
|
||||
|
||||
if strings.HasPrefix(storageURL, "samba://") {
|
||||
fileStorage, err := CreateFileStorage(storageURL[8:], true, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the file storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
return fileStorage
|
||||
}
|
||||
|
||||
urlRegex := regexp.MustCompile(`^([\w-]+)://([\w\-@\.]+@)?([^/]+)(/(.+))?`)
|
||||
|
||||
matched := urlRegex.FindStringSubmatch(storageURL)
|
||||
|
||||
@@ -171,7 +276,7 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
|
||||
if strings.Contains(server, ":") {
|
||||
index := strings.Index(server, ":")
|
||||
port, _ = strconv.Atoi(server[index + 1:])
|
||||
port, _ = strconv.Atoi(server[index+1:])
|
||||
server = server[:index]
|
||||
}
|
||||
|
||||
@@ -181,9 +286,12 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
username = username[:len(username) - 1]
|
||||
username = username[:len(username)-1]
|
||||
}
|
||||
|
||||
// If ssh_key_file is set, skip password-based login
|
||||
keyFile := GetPasswordFromPreference(preference, "ssh_key_file")
|
||||
|
||||
password := ""
|
||||
passwordCallback := func() (string, error) {
|
||||
LOG_DEBUG("SSH_PASSWORD", "Attempting password login")
|
||||
@@ -191,23 +299,22 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
return password, nil
|
||||
}
|
||||
|
||||
keyboardInteractive := func (user, instruction string, questions []string, echos []bool) (answers []string,
|
||||
keyboardInteractive := func(user, instruction string, questions []string, echos []bool) (answers []string,
|
||||
err error) {
|
||||
if len(questions) == 1 {
|
||||
LOG_DEBUG("SSH_INTERACTIVE", "Attempting keyboard interactive login")
|
||||
password = GetPassword(preference, "ssh_password", "Enter SSH password:", false, resetPassword)
|
||||
answers = []string { password }
|
||||
answers = []string{password}
|
||||
return answers, nil
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
keyFile := ""
|
||||
publicKeysCallback := func() ([]ssh.Signer, error) {
|
||||
LOG_DEBUG("SSH_PUBLICKEY", "Attempting public key authentication")
|
||||
|
||||
signers := []ssh.Signer {}
|
||||
signers := []ssh.Signer{}
|
||||
|
||||
agentSock := os.Getenv("SSH_AUTH_SOCK")
|
||||
if agentSock != "" {
|
||||
@@ -219,6 +326,8 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
signers, err = sshAgent.Signers()
|
||||
if err != nil {
|
||||
LOG_DEBUG("SSH_AGENT", "Can't log in using public key authentication via agent: %v", err)
|
||||
} else if len(signers) == 0 {
|
||||
LOG_DEBUG("SSH_AGENT", "SSH agent doesn't return any signer")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,11 +365,19 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
|
||||
}
|
||||
|
||||
authMethods := [] ssh.AuthMethod {
|
||||
authMethods := []ssh.AuthMethod{}
|
||||
passwordAuthMethods := []ssh.AuthMethod{
|
||||
ssh.PasswordCallback(passwordCallback),
|
||||
ssh.KeyboardInteractive(keyboardInteractive),
|
||||
}
|
||||
keyFileAuthMethods := []ssh.AuthMethod{
|
||||
ssh.PublicKeysCallback(publicKeysCallback),
|
||||
}
|
||||
if keyFile != "" {
|
||||
authMethods = append(keyFileAuthMethods, passwordAuthMethods...)
|
||||
} else {
|
||||
authMethods = append(passwordAuthMethods, keyFileAuthMethods...)
|
||||
}
|
||||
|
||||
if RunInBackground {
|
||||
|
||||
@@ -271,7 +388,7 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
keyFileKey = preference.Name + "_" + keyFileKey
|
||||
}
|
||||
|
||||
authMethods = [] ssh.AuthMethod {}
|
||||
authMethods = []ssh.AuthMethod{}
|
||||
if keyringGet(passwordKey) != "" {
|
||||
authMethods = append(authMethods, ssh.PasswordCallback(passwordCallback))
|
||||
authMethods = append(authMethods, ssh.KeyboardInteractive(keyboardInteractive))
|
||||
@@ -282,10 +399,10 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
}
|
||||
|
||||
hostKeyChecker := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
return checkHostKey(repository, hostname, remote, key)
|
||||
return checkHostKey(hostname, remote, key)
|
||||
}
|
||||
|
||||
sftpStorage, err := CreateSFTPStorage(server, port, username, storageDir, authMethods, hostKeyChecker, threads)
|
||||
sftpStorage, err := CreateSFTPStorage(server, port, username, storageDir, 2, authMethods, hostKeyChecker, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the SFTP storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
@@ -297,7 +414,7 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
SavePassword(preference, "ssh_password", password)
|
||||
}
|
||||
return sftpStorage
|
||||
} else if matched[1] == "s3" {
|
||||
} else if matched[1] == "s3" || matched[1] == "s3c" || matched[1] == "minio" || matched[1] == "minios" {
|
||||
|
||||
// urlRegex := regexp.MustCompile(`^(\w+)://([\w\-]+@)?([^/]+)(/(.+))?`)
|
||||
|
||||
@@ -306,7 +423,7 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
bucket := matched[5]
|
||||
|
||||
if region != "" {
|
||||
region = region[:len(region) - 1]
|
||||
region = region[:len(region)-1]
|
||||
}
|
||||
|
||||
if strings.EqualFold(endpoint, "amazon") || strings.EqualFold(endpoint, "amazon.com") {
|
||||
@@ -316,26 +433,74 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
storageDir := ""
|
||||
if strings.Contains(bucket, "/") {
|
||||
firstSlash := strings.Index(bucket, "/")
|
||||
storageDir = bucket[firstSlash + 1:]
|
||||
storageDir = bucket[firstSlash+1:]
|
||||
bucket = bucket[:firstSlash]
|
||||
}
|
||||
|
||||
accessKey := GetPassword(preference, "s3_id", "Enter S3 Access Key ID:", true, resetPassword)
|
||||
secretKey := GetPassword(preference, "s3_secret", "Enter S3 Secret Access Key:", true, resetPassword)
|
||||
|
||||
s3Storage, err := CreateS3Storage(region, endpoint, bucket, storageDir, accessKey, secretKey, threads)
|
||||
var err error
|
||||
|
||||
if matched[1] == "s3c" {
|
||||
storage, err = CreateS3CStorage(region, endpoint, bucket, storageDir, accessKey, secretKey, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the S3C storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
isMinioCompatible := (matched[1] == "minio" || matched[1] == "minios")
|
||||
isSSLSupported := (matched[1] == "s3" || matched[1] == "minios")
|
||||
storage, err = CreateS3Storage(region, endpoint, bucket, storageDir, accessKey, secretKey, threads, isSSLSupported, isMinioCompatible)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the S3 storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
SavePassword(preference, "s3_id", accessKey)
|
||||
SavePassword(preference, "s3_secret", secretKey)
|
||||
|
||||
return s3Storage
|
||||
return storage
|
||||
|
||||
} else if matched[1] == "wasabi" {
|
||||
|
||||
region := matched[2]
|
||||
endpoint := matched[3]
|
||||
bucket := matched[5]
|
||||
|
||||
if region != "" {
|
||||
region = region[:len(region)-1]
|
||||
}
|
||||
|
||||
key := GetPassword(preference, "wasabi_key",
|
||||
"Enter Wasabi key:", true, resetPassword)
|
||||
secret := GetPassword(preference, "wasabi_secret",
|
||||
"Enter Wasabi secret:", true, resetPassword)
|
||||
|
||||
storageDir := ""
|
||||
if strings.Contains(bucket, "/") {
|
||||
firstSlash := strings.Index(bucket, "/")
|
||||
storageDir = bucket[firstSlash+1:]
|
||||
bucket = bucket[:firstSlash]
|
||||
}
|
||||
|
||||
storage, err := CreateWasabiStorage(region, endpoint,
|
||||
bucket, storageDir, key, secret, threads)
|
||||
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the Wasabi storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
SavePassword(preference, "wasabi_key", key)
|
||||
SavePassword(preference, "wasabi_secret", secret)
|
||||
|
||||
return storage
|
||||
|
||||
} else if matched[1] == "dropbox" {
|
||||
storageDir := matched[3] + matched[5]
|
||||
token := GetPassword(preference, "dropbox_token", "Enter Dropbox access token:", true, resetPassword)
|
||||
dropboxStorage, err := CreateDropboxStorage(token, storageDir, threads)
|
||||
dropboxStorage, err := CreateDropboxStorage(token, storageDir, 1, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the dropbox storage: %v", err)
|
||||
return nil
|
||||
@@ -431,6 +596,39 @@ func CreateStorage(repository string, preference Preference, resetPassword bool,
|
||||
}
|
||||
SavePassword(preference, "hubic_token", tokenFile)
|
||||
return hubicStorage
|
||||
} else if matched[1] == "swift" {
|
||||
prompt := fmt.Sprintf("Enter the OpenStack Swift key:")
|
||||
key := GetPassword(preference, "swift_key", prompt, true, resetPassword)
|
||||
swiftStorage, err := CreateSwiftStorage(storageURL[8:], key, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the OpenStack Swift storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
SavePassword(preference, "swift_key", key)
|
||||
return swiftStorage
|
||||
} else if matched[1] == "webdav" || matched[1] == "webdav-http" {
|
||||
server := matched[3]
|
||||
username := matched[2]
|
||||
username = username[:len(username) - 1]
|
||||
storageDir := matched[5]
|
||||
port := 0
|
||||
useHTTP := matched[1] == "webdav-http"
|
||||
|
||||
if strings.Contains(server, ":") {
|
||||
index := strings.Index(server, ":")
|
||||
port, _ = strconv.Atoi(server[index+1:])
|
||||
server = server[:index]
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("Enter the WebDAV password:")
|
||||
password := GetPassword(preference, "webdav_password", prompt, true, resetPassword)
|
||||
webDAVStorage, err := CreateWebDAVStorage(server, port, username, password, storageDir, useHTTP, threads)
|
||||
if err != nil {
|
||||
LOG_ERROR("STORAGE_CREATE", "Failed to load the WebDAV storage at %s: %v", storageURL, err)
|
||||
return nil
|
||||
}
|
||||
SavePassword(preference, "webdav_password", password)
|
||||
return webDAVStorage
|
||||
} else {
|
||||
LOG_ERROR("STORAGE_CREATE", "The storage type '%s' is not supported", matched[1])
|
||||
return nil
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"time"
|
||||
"flag"
|
||||
"path"
|
||||
"testing"
|
||||
"strings"
|
||||
"strconv"
|
||||
"io/ioutil"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
"math/rand"
|
||||
@@ -41,54 +40,124 @@ func init() {
|
||||
func loadStorage(localStoragePath string, threads int) (Storage, error) {
|
||||
|
||||
if testStorageName == "" || testStorageName == "file" {
|
||||
return CreateFileStorage(localStoragePath, threads)
|
||||
storage, err := CreateFileStorage(localStoragePath, false, threads)
|
||||
if storage != nil {
|
||||
// Use a read level of at least 2 because this will catch more errors than a read level of 1.
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
}
|
||||
return storage, err
|
||||
}
|
||||
|
||||
config, err := ioutil.ReadFile("test_storage.conf")
|
||||
description, err := ioutil.ReadFile("test_storage.conf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storages := make(map[string]map[string]string)
|
||||
configs := make(map[string]map[string]string)
|
||||
|
||||
err = json.Unmarshal(config, &storages)
|
||||
err = json.Unmarshal(description, &configs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage, found := storages[testStorageName]
|
||||
config, found := configs[testStorageName]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("No storage named '%s' found", testStorageName)
|
||||
}
|
||||
|
||||
if testStorageName == "sftp" {
|
||||
port, _ := strconv.Atoi(storage["port"])
|
||||
return CreateSFTPStorageWithPassword(storage["server"], port, storage["username"], storage["directory"], storage["password"], threads)
|
||||
if testStorageName == "flat" {
|
||||
storage, err := CreateFileStorage(localStoragePath, false, threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "samba" {
|
||||
storage, err := CreateFileStorage(localStoragePath, true, threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "sftp" {
|
||||
port, _ := strconv.Atoi(config["port"])
|
||||
storage, err := CreateSFTPStorageWithPassword(config["server"], port, config["username"], config["directory"], 2, config["password"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "s3" {
|
||||
return CreateS3Storage(storage["region"], storage["endpoint"], storage["bucket"], storage["directory"], storage["access_key"], storage["secret_key"], threads)
|
||||
storage, err := CreateS3Storage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads, true, false)
|
||||
return storage, err
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
} else if testStorageName == "wasabi" {
|
||||
storage, err := CreateWasabiStorage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads)
|
||||
return storage, err
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
} else if testStorageName == "s3c" {
|
||||
storage, err := CreateS3CStorage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "digitalocean" {
|
||||
storage, err := CreateS3CStorage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "minio" {
|
||||
storage, err := CreateS3Storage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads, false, true)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "minios" {
|
||||
storage, err := CreateS3Storage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads, true, true)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "dropbox" {
|
||||
return CreateDropboxStorage(storage["token"], storage["directory"], threads)
|
||||
storage, err := CreateDropboxStorage(config["token"], config["directory"], 1, threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "b2" {
|
||||
return CreateB2Storage(storage["account"], storage["key"], storage["bucket"], threads)
|
||||
storage, err := CreateB2Storage(config["account"], config["key"], config["bucket"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "gcs-s3" {
|
||||
return CreateS3Storage(storage["region"], storage["endpoint"], storage["bucket"], storage["directory"], storage["access_key"], storage["secret_key"], threads)
|
||||
storage, err := CreateS3Storage(config["region"], config["endpoint"], config["bucket"], config["directory"], config["access_key"], config["secret_key"], threads, true, false)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "gcs" {
|
||||
return CreateGCSStorage(storage["token_file"], storage["bucket"], storage["directory"], threads)
|
||||
storage, err := CreateGCSStorage(config["token_file"], config["bucket"], config["directory"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "gcs-sa" {
|
||||
return CreateGCSStorage(storage["token_file"], storage["bucket"], storage["directory"], threads)
|
||||
storage, err := CreateGCSStorage(config["token_file"], config["bucket"], config["directory"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "azure" {
|
||||
return CreateAzureStorage(storage["account"], storage["key"], storage["container"], threads)
|
||||
storage, err := CreateAzureStorage(config["account"], config["key"], config["container"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "acd" {
|
||||
return CreateACDStorage(storage["token_file"], storage["storage_path"], threads)
|
||||
storage, err := CreateACDStorage(config["token_file"], config["storage_path"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "gcd" {
|
||||
return CreateGCDStorage(storage["token_file"], storage["storage_path"], threads)
|
||||
storage, err := CreateGCDStorage(config["token_file"], config["storage_path"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "one" {
|
||||
return CreateOneDriveStorage(storage["token_file"], storage["storage_path"], threads)
|
||||
storage, err := CreateOneDriveStorage(config["token_file"], config["storage_path"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "hubic" {
|
||||
return CreateHubicStorage(storage["token_file"], storage["storage_path"], threads)
|
||||
storage, err := CreateHubicStorage(config["token_file"], config["storage_path"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "memset" {
|
||||
storage, err := CreateSwiftStorage(config["storage_url"], config["key"], threads)
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else if testStorageName == "pcloud" || testStorageName == "box" {
|
||||
storage, err := CreateWebDAVStorage(config["host"], 0, config["username"], config["password"], config["storage_path"], false, threads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage.SetDefaultNestingLevels([]int{2, 3}, 2)
|
||||
return storage, err
|
||||
} else {
|
||||
return nil, fmt.Errorf("Invalid storage named: %s", testStorageName)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Invalid storage named: %s", testStorageName)
|
||||
}
|
||||
|
||||
func cleanStorage(storage Storage) {
|
||||
@@ -101,8 +170,8 @@ func cleanStorage(storage Storage) {
|
||||
LOG_INFO("STORAGE_LIST", "Listing snapshots in the storage")
|
||||
for len(directories) > 0 {
|
||||
|
||||
dir := directories[len(directories) - 1]
|
||||
directories = directories[:len(directories) - 1]
|
||||
dir := directories[len(directories)-1]
|
||||
directories = directories[:len(directories)-1]
|
||||
|
||||
files, _, err := storage.ListFiles(0, dir)
|
||||
if err != nil {
|
||||
@@ -111,10 +180,10 @@ func cleanStorage(storage Storage) {
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if len(file) > 0 && file[len(file) - 1] == '/' {
|
||||
directories = append(directories, dir + file)
|
||||
if len(file) > 0 && file[len(file)-1] == '/' {
|
||||
directories = append(directories, dir+file)
|
||||
} else {
|
||||
snapshots = append(snapshots, dir + file)
|
||||
snapshots = append(snapshots, dir+file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +194,7 @@ func cleanStorage(storage Storage) {
|
||||
}
|
||||
|
||||
for _, chunk := range listChunks(storage) {
|
||||
storage.DeleteFile(0, "chunks/" + chunk)
|
||||
storage.DeleteFile(0, "chunks/"+chunk)
|
||||
}
|
||||
|
||||
storage.DeleteFile(0, "config")
|
||||
@@ -141,8 +210,8 @@ func listChunks(storage Storage) (chunks []string) {
|
||||
|
||||
for len(directories) > 0 {
|
||||
|
||||
dir := directories[len(directories) - 1]
|
||||
directories = directories[:len(directories) - 1]
|
||||
dir := directories[len(directories)-1]
|
||||
directories = directories[:len(directories)-1]
|
||||
|
||||
files, _, err := storage.ListFiles(0, dir)
|
||||
if err != nil {
|
||||
@@ -151,8 +220,8 @@ func listChunks(storage Storage) (chunks []string) {
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if len(file) > 0 && file[len(file) - 1] == '/' {
|
||||
directories = append(directories, dir + file)
|
||||
if len(file) > 0 && file[len(file)-1] == '/' {
|
||||
directories = append(directories, dir+file)
|
||||
} else {
|
||||
chunk := dir + file
|
||||
chunk = chunk[len("chunks/"):]
|
||||
@@ -175,7 +244,7 @@ func moveChunk(t *testing.T, storage Storage, chunkID string, isFossil bool, del
|
||||
|
||||
to := filePath + ".fsl"
|
||||
if isFossil {
|
||||
to = filePath[:len(filePath) - len(".fsl")]
|
||||
to = filePath[:len(filePath)-len(".fsl")]
|
||||
}
|
||||
|
||||
err = storage.MoveFile(0, filePath, to)
|
||||
@@ -222,7 +291,7 @@ func TestStorage(t *testing.T) {
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
} ()
|
||||
}()
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "storage_test")
|
||||
os.RemoveAll(testDir)
|
||||
@@ -246,7 +315,7 @@ func TestStorage(t *testing.T) {
|
||||
delay = 2
|
||||
}
|
||||
|
||||
for _, dir := range []string { "chunks", "snapshots" } {
|
||||
for _, dir := range []string{"chunks", "snapshots"} {
|
||||
err = storage.CreateDirectory(0, dir)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create directory %s: %v", dir, err)
|
||||
@@ -256,6 +325,33 @@ func TestStorage(t *testing.T) {
|
||||
|
||||
storage.CreateDirectory(0, "snapshots/repository1")
|
||||
storage.CreateDirectory(0, "snapshots/repository2")
|
||||
|
||||
storage.CreateDirectory(0, "shared")
|
||||
|
||||
// Upload to the same directory by multiple goroutines
|
||||
count := 8
|
||||
finished := make(chan int, count)
|
||||
for i := 0; i < count; i++ {
|
||||
go func(name string) {
|
||||
err := storage.UploadFile(0, name, []byte("this is a test file"))
|
||||
if err != nil {
|
||||
t.Errorf("Error to upload '%s': %v", name, err)
|
||||
}
|
||||
finished <- 0
|
||||
}(fmt.Sprintf("shared/a/b/c/%d", i))
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
<-finished
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
storage.DeleteFile(0, fmt.Sprintf("shared/a/b/c/%d", i))
|
||||
}
|
||||
storage.DeleteFile(0, "shared/a/b/c")
|
||||
storage.DeleteFile(0, "shared/a/b")
|
||||
storage.DeleteFile(0, "shared/a")
|
||||
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
{
|
||||
|
||||
@@ -289,10 +385,11 @@ func TestStorage(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshotIDs := []string {}
|
||||
snapshotIDs := []string{}
|
||||
for _, snapshotDir := range snapshotDirs {
|
||||
if len(snapshotDir) > 0 && snapshotDir[len(snapshotDir) - 1] == '/' {
|
||||
snapshotIDs = append(snapshotIDs, snapshotDir[:len(snapshotDir) - 1])
|
||||
LOG_INFO("debug", "snapshot dir: %s", snapshotDir)
|
||||
if len(snapshotDir) > 0 && snapshotDir[len(snapshotDir)-1] == '/' {
|
||||
snapshotIDs = append(snapshotIDs, snapshotDir[:len(snapshotDir)-1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +399,13 @@ func TestStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, snapshotID := range snapshotIDs {
|
||||
snapshots, _, err := storage.ListFiles(0, "snapshots/" + snapshotID)
|
||||
snapshots, _, err := storage.ListFiles(0, "snapshots/"+snapshotID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to list snapshots for %s: %v", snapshotID, err)
|
||||
return
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
storage.DeleteFile(0, "snapshots/" + snapshotID + "/" + snapshot)
|
||||
storage.DeleteFile(0, "snapshots/"+snapshotID+"/"+snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +413,7 @@ func TestStorage(t *testing.T) {
|
||||
|
||||
storage.DeleteFile(0, "config")
|
||||
|
||||
for _, file := range []string { "snapshots/repository1/1", "snapshots/repository2/1"} {
|
||||
for _, file := range []string{"snapshots/repository1/1", "snapshots/repository2/1"} {
|
||||
exist, _, _, err := storage.GetFileInfo(0, file)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get file info for %s: %v", file, err)
|
||||
@@ -328,7 +425,7 @@ func TestStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
numberOfFiles := 20
|
||||
numberOfFiles := 10
|
||||
maxFileSize := 64 * 1024
|
||||
|
||||
if testQuickMode {
|
||||
@@ -338,7 +435,7 @@ func TestStorage(t *testing.T) {
|
||||
chunks := []string{}
|
||||
|
||||
for i := 0; i < numberOfFiles; i++ {
|
||||
content := make([]byte, rand.Int() % maxFileSize + 1)
|
||||
content := make([]byte, rand.Int()%maxFileSize+1)
|
||||
_, err = crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -364,15 +461,7 @@ func TestStorage(t *testing.T) {
|
||||
t.Errorf("Failed to upload the file %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
LOG_INFO("STORAGE_CHUNK", "Uploaded chunk: %s, size: %d", chunkID, len(content))
|
||||
}
|
||||
|
||||
allChunks := [] string {}
|
||||
for _, file := range listChunks(storage) {
|
||||
file = strings.Replace(file, "/", "", -1)
|
||||
if len(file) == 64 {
|
||||
allChunks = append(allChunks, file)
|
||||
}
|
||||
LOG_INFO("STORAGE_CHUNK", "Uploaded chunk: %s, size: %d", filePath, len(content))
|
||||
}
|
||||
|
||||
LOG_INFO("STORAGE_FOSSIL", "Making %s a fossil", chunks[0])
|
||||
@@ -382,11 +471,10 @@ func TestStorage(t *testing.T) {
|
||||
|
||||
config := CreateConfig()
|
||||
config.MinimumChunkSize = 100
|
||||
config.chunkPool = make(chan *Chunk, numberOfFiles * 2)
|
||||
config.chunkPool = make(chan *Chunk, numberOfFiles*2)
|
||||
|
||||
chunk := CreateChunk(config, true)
|
||||
|
||||
|
||||
for _, chunkID := range chunks {
|
||||
|
||||
chunk.Reset(false)
|
||||
@@ -403,7 +491,7 @@ func TestStorage(t *testing.T) {
|
||||
t.Errorf("Error downloading file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
LOG_INFO("STORAGE_CHUNK", "Downloaded chunk: %s, size: %d", chunkID, chunk.GetLength())
|
||||
LOG_INFO("STORAGE_CHUNK", "Downloaded chunk: %s, size: %d", filePath, chunk.GetLength())
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
@@ -426,7 +514,7 @@ func TestStorage(t *testing.T) {
|
||||
} else {
|
||||
err = storage.DeleteFile(0, filePath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete file %s: %v", filePath)
|
||||
t.Errorf("Failed to delete file %s: %v", filePath, err)
|
||||
} else {
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
filePath, exist, _, err = storage.FindChunk(0, chunks[1], true)
|
||||
@@ -438,9 +526,14 @@ func TestStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
allChunks := []string{}
|
||||
for _, file := range listChunks(storage) {
|
||||
allChunks = append(allChunks, file)
|
||||
}
|
||||
|
||||
for _, file := range allChunks {
|
||||
|
||||
err = storage.DeleteFile(0, "chunks/" + file)
|
||||
err = storage.DeleteFile(0, "chunks/"+file)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete the file %s: %v", file, err)
|
||||
return
|
||||
@@ -448,3 +541,71 @@ func TestStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCleanStorage(t *testing.T) {
|
||||
setTestingT(t)
|
||||
SetLoggingLevel(INFO)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch e := r.(type) {
|
||||
case Exception:
|
||||
t.Errorf("%s %s", e.LogID, e.Message)
|
||||
debug.PrintStack()
|
||||
default:
|
||||
t.Errorf("%v", e)
|
||||
debug.PrintStack()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
testDir := path.Join(os.TempDir(), "duplicacy_test", "storage_test")
|
||||
os.RemoveAll(testDir)
|
||||
os.MkdirAll(testDir, 0700)
|
||||
|
||||
LOG_INFO("STORAGE_TEST", "storage: %s", testStorageName)
|
||||
|
||||
storage, err := loadStorage(testDir, 1)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create storage: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
directories := make([]string, 0, 1024)
|
||||
directories = append(directories, "snapshots/")
|
||||
directories = append(directories, "chunks/")
|
||||
|
||||
for len(directories) > 0 {
|
||||
|
||||
dir := directories[len(directories)-1]
|
||||
directories = directories[:len(directories)-1]
|
||||
|
||||
LOG_INFO("LIST_FILES", "Listing %s", dir)
|
||||
|
||||
files, _, err := storage.ListFiles(0, dir)
|
||||
if err != nil {
|
||||
LOG_ERROR("LIST_FILES", "Failed to list the directory %s: %v", dir, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if len(file) > 0 && file[len(file)-1] == '/' {
|
||||
directories = append(directories, dir+file)
|
||||
} else {
|
||||
storage.DeleteFile(0, dir+file)
|
||||
LOG_INFO("DELETE_FILE", "Deleted file %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storage.DeleteFile(0, "config")
|
||||
LOG_INFO("DELETE_FILE", "Deleted config")
|
||||
|
||||
files, _, err := storage.ListFiles(0, "chunks/")
|
||||
for _, file := range files {
|
||||
if len(file) > 0 && file[len(file)-1] != '/' {
|
||||
LOG_DEBUG("FILE_EXIST", "File %s exists after deletion", file)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
251
src/duplicacy_swiftstorage.go
Normal file
251
src/duplicacy_swiftstorage.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ncw/swift"
|
||||
)
|
||||
|
||||
type SwiftStorage struct {
|
||||
StorageBase
|
||||
|
||||
connection *swift.Connection
|
||||
container string
|
||||
storageDir string
|
||||
threads int
|
||||
}
|
||||
|
||||
// CreateSwiftStorage creates an OpenStack Swift storage object. storageURL is in the form of
|
||||
// `user@authURL/container/path?arg1=value1&arg2=value2``
|
||||
func CreateSwiftStorage(storageURL string, key string, threads int) (storage *SwiftStorage, err error) {
|
||||
|
||||
// This is the map to store all arguments
|
||||
arguments := make(map[string]string)
|
||||
|
||||
// Check if there are arguments provided as a query string
|
||||
if strings.Contains(storageURL, "?") {
|
||||
urlAndArguments := strings.SplitN(storageURL, "?", 2)
|
||||
storageURL = urlAndArguments[0]
|
||||
for _, pair := range strings.Split(urlAndArguments[1], "&") {
|
||||
if strings.Contains(pair, "=") {
|
||||
keyAndValue := strings.Split(pair, "=")
|
||||
arguments[keyAndValue[0]] = keyAndValue[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take out the user name if there is one
|
||||
if strings.Contains(storageURL, "@") {
|
||||
userAndURL := strings.Split(storageURL, "@")
|
||||
arguments["user"] = userAndURL[0]
|
||||
storageURL = userAndURL[1]
|
||||
}
|
||||
|
||||
// The version is used to split authURL and container/path
|
||||
versions := []string{"/v1/", "/v1.0/", "/v2/", "/v2.0/", "/v3/", "/v3.0/", "/v4/", "/v4.0/"}
|
||||
storageDir := ""
|
||||
for _, version := range versions {
|
||||
if strings.Contains(storageURL, version) {
|
||||
urlAndStorageDir := strings.SplitN(storageURL, version, 2)
|
||||
storageURL = urlAndStorageDir[0] + version[0:len(version)-1]
|
||||
storageDir = urlAndStorageDir[1]
|
||||
}
|
||||
}
|
||||
|
||||
// If no container/path is specified, find them from the arguments
|
||||
if storageDir == "" {
|
||||
storageDir = arguments["storage_dir"]
|
||||
}
|
||||
|
||||
// Now separate the container name from the storage path
|
||||
container := ""
|
||||
if strings.Contains(storageDir, "/") {
|
||||
containerAndStorageDir := strings.SplitN(storageDir, "/", 2)
|
||||
container = containerAndStorageDir[0]
|
||||
storageDir = containerAndStorageDir[1]
|
||||
if len(storageDir) > 0 && storageDir[len(storageDir)-1] != '/' {
|
||||
storageDir += "/"
|
||||
}
|
||||
} else {
|
||||
container = storageDir
|
||||
storageDir = ""
|
||||
}
|
||||
|
||||
// Number of retries on err
|
||||
retries := 4
|
||||
if value, ok := arguments["retries"]; ok {
|
||||
retries, _ = strconv.Atoi(value)
|
||||
}
|
||||
|
||||
// Connect channel timeout
|
||||
connectionTimeout := 10
|
||||
if value, ok := arguments["connection_timeout"]; ok {
|
||||
connectionTimeout, _ = strconv.Atoi(value)
|
||||
}
|
||||
|
||||
// Data channel timeout
|
||||
timeout := 60
|
||||
if value, ok := arguments["timeout"]; ok {
|
||||
timeout, _ = strconv.Atoi(value)
|
||||
}
|
||||
|
||||
// Auth version; default to auto-detect
|
||||
authVersion := 0
|
||||
if value, ok := arguments["auth_version"]; ok {
|
||||
authVersion, _ = strconv.Atoi(value)
|
||||
}
|
||||
|
||||
// Allow http to be used by setting "protocol=http" in arguments
|
||||
if _, ok := arguments["protocol"]; !ok {
|
||||
arguments["protocol"] = "https"
|
||||
}
|
||||
|
||||
// Please refer to https://godoc.org/github.com/ncw/swift#Connection
|
||||
connection := swift.Connection{
|
||||
Domain: arguments["domain"],
|
||||
DomainId: arguments["domain_id"],
|
||||
UserName: arguments["user"],
|
||||
UserId: arguments["user_id"],
|
||||
ApiKey: key,
|
||||
AuthUrl: arguments["protocol"] + "://" + storageURL,
|
||||
Retries: retries,
|
||||
UserAgent: arguments["user_agent"],
|
||||
ConnectTimeout: time.Duration(connectionTimeout) * time.Second,
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
Region: arguments["region"],
|
||||
AuthVersion: authVersion,
|
||||
Internal: false,
|
||||
Tenant: arguments["tenant"],
|
||||
TenantId: arguments["tenant_id"],
|
||||
EndpointType: swift.EndpointType(arguments["endpiont_type"]),
|
||||
TenantDomain: arguments["tenant_domain"],
|
||||
TenantDomainId: arguments["tenant_domain_id"],
|
||||
TrustId: arguments["trust_id"],
|
||||
}
|
||||
|
||||
_, _, err = connection.Container(container)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage = &SwiftStorage{
|
||||
connection: &connection,
|
||||
container: container,
|
||||
storageDir: storageDir,
|
||||
threads: threads,
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{1}, 1)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir' (non-recursively)
|
||||
func (storage *SwiftStorage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {
|
||||
if len(dir) > 0 && dir[len(dir)-1] != '/' {
|
||||
dir += "/"
|
||||
}
|
||||
isSnapshotDir := dir == "snapshots/"
|
||||
dir = storage.storageDir + dir
|
||||
|
||||
options := swift.ObjectsOpts{
|
||||
Prefix: dir,
|
||||
Limit: 1000,
|
||||
}
|
||||
|
||||
if isSnapshotDir {
|
||||
options.Delimiter = '/'
|
||||
}
|
||||
|
||||
objects, err := storage.connection.ObjectsAll(storage.container, &options)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, obj := range objects {
|
||||
if isSnapshotDir {
|
||||
if obj.SubDir != "" {
|
||||
files = append(files, obj.SubDir[len(dir):])
|
||||
sizes = append(sizes, 0)
|
||||
}
|
||||
} else {
|
||||
files = append(files, obj.Name[len(dir):])
|
||||
sizes = append(sizes, obj.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
return files, sizes, nil
|
||||
}
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *SwiftStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
return storage.connection.ObjectDelete(storage.container, storage.storageDir+filePath)
|
||||
}
|
||||
|
||||
// MoveFile renames the file.
|
||||
func (storage *SwiftStorage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
return storage.connection.ObjectMove(storage.container, storage.storageDir+from,
|
||||
storage.container, storage.storageDir+to)
|
||||
}
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *SwiftStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
// Does nothing as directories do not exist in OpenStack Swift
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *SwiftStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
object, _, err := storage.connection.Object(storage.container, storage.storageDir+filePath)
|
||||
|
||||
if err != nil {
|
||||
if err == swift.ObjectNotFound {
|
||||
return false, false, 0, nil
|
||||
} else {
|
||||
return false, false, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, false, object.Bytes, nil
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *SwiftStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
|
||||
file, _, err := storage.connection.ObjectOpen(storage.container, storage.storageDir+filePath, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = RateLimitedCopy(chunk, file, storage.DownloadRateLimit/storage.threads)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *SwiftStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
reader := CreateRateLimitedReader(content, storage.UploadRateLimit/storage.threads)
|
||||
_, err = storage.connection.ObjectPut(storage.container, storage.storageDir+filePath, reader, true, "", "application/duplicacy", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *SwiftStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *SwiftStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *SwiftStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *SwiftStorage) IsFastListing() bool { return true }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *SwiftStorage) EnableTestMode() {
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"strconv"
|
||||
"runtime"
|
||||
"crypto/sha256"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"github.com/gilbertchen/gopass"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
var RunInBackground bool = false
|
||||
@@ -32,15 +31,60 @@ type RateLimitedReader struct {
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
func CreateRateLimitedReader(content []byte, rate int) (*RateLimitedReader) {
|
||||
return &RateLimitedReader {
|
||||
var RegexMap map[string]*regexp.Regexp
|
||||
|
||||
func init() {
|
||||
|
||||
if RegexMap == nil {
|
||||
RegexMap = make(map[string]*regexp.Regexp)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func CreateRateLimitedReader(content []byte, rate int) *RateLimitedReader {
|
||||
return &RateLimitedReader{
|
||||
Content: content,
|
||||
Rate: float64(rate * 1024),
|
||||
Next: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *RateLimitedReader) Length() (int64) {
|
||||
func IsEmptyFilter(pattern string) bool {
|
||||
if pattern == "+" || pattern == "-" || pattern == "i:" || pattern == "e:" {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsUnspecifiedFilter(pattern string) bool {
|
||||
if pattern[0] != '+' && pattern[0] != '-' && pattern[0] != 'i' && pattern[0] != 'e' {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidRegex(pattern string) (valid bool, err error) {
|
||||
|
||||
var re *regexp.Regexp = nil
|
||||
|
||||
if re, valid = RegexMap[pattern]; valid && re != nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
re, err = regexp.Compile(pattern)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
} else {
|
||||
RegexMap[pattern] = re
|
||||
LOG_DEBUG("REGEX_STORED", "Saved compiled regex for pattern \"%s\", regex=%#v", pattern, re)
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *RateLimitedReader) Length() int64 {
|
||||
return int64(len(reader.Content))
|
||||
}
|
||||
|
||||
@@ -79,19 +123,19 @@ func (reader *RateLimitedReader) Read(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
elapsed := time.Since(reader.StartTime).Seconds()
|
||||
delay := float64(reader.Next) / reader.Rate - elapsed
|
||||
end := reader.Next + int(reader.Rate / 5)
|
||||
delay := float64(reader.Next)/reader.Rate - elapsed
|
||||
end := reader.Next + int(reader.Rate/5)
|
||||
if delay > 0 {
|
||||
time.Sleep(time.Duration(delay * float64(time.Second)))
|
||||
} else {
|
||||
end += - int(delay * reader.Rate)
|
||||
end += -int(delay * reader.Rate)
|
||||
}
|
||||
|
||||
if end > len(reader.Content) {
|
||||
end = len(reader.Content)
|
||||
}
|
||||
|
||||
n = copy(p, reader.Content[reader.Next : end])
|
||||
n = copy(p, reader.Content[reader.Next:end])
|
||||
reader.Next += n
|
||||
return n, nil
|
||||
}
|
||||
@@ -101,7 +145,7 @@ func RateLimitedCopy(writer io.Writer, reader io.Reader, rate int) (written int6
|
||||
return io.Copy(writer, reader)
|
||||
}
|
||||
for range time.Tick(time.Second / 5) {
|
||||
n, err := io.CopyN(writer, reader, int64(rate * 1024 / 5))
|
||||
n, err := io.CopyN(writer, reader, int64(rate*1024/5))
|
||||
written += n
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
@@ -115,14 +159,12 @@ func RateLimitedCopy(writer io.Writer, reader io.Reader, rate int) (written int6
|
||||
}
|
||||
|
||||
// GenerateKeyFromPassword generates a key from the password.
|
||||
func GenerateKeyFromPassword(password string) []byte {
|
||||
return pbkdf2.Key([]byte(password), DEFAULT_KEY, 16384, 32, sha256.New)
|
||||
func GenerateKeyFromPassword(password string, salt []byte, iterations int) []byte {
|
||||
return pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New)
|
||||
}
|
||||
|
||||
// GetPassword attempts to get the password from KeyChain/KeyRing, environment variables, or keyboard input.
|
||||
func GetPassword(preference Preference, passwordType string, prompt string,
|
||||
showPassword bool, resetPassword bool) (string) {
|
||||
|
||||
// Get password from preference, env, but don't start any keyring request
|
||||
func GetPasswordFromPreference(preference Preference, passwordType string) string {
|
||||
passwordID := passwordType
|
||||
if preference.Name != "default" {
|
||||
passwordID = preference.Name + "_" + passwordID
|
||||
@@ -136,16 +178,42 @@ func GetPassword(preference Preference, passwordType string, prompt string,
|
||||
}
|
||||
}
|
||||
|
||||
// If the password is stored in the preference, there is no need to include the storage name
|
||||
// (i.e., preference.Name) in the key, so the key name should really be passwordType rather
|
||||
// than passwordID; we're using passwordID here only for backward compatibility
|
||||
if len(preference.Keys) > 0 && len(preference.Keys[passwordID]) > 0 {
|
||||
LOG_DEBUG("PASSWORD_KEYCHAIN", "Reading %s from preferences", passwordID)
|
||||
LOG_DEBUG("PASSWORD_PREFERENCE", "Reading %s from preferences", passwordID)
|
||||
return preference.Keys[passwordID]
|
||||
}
|
||||
|
||||
if len(preference.Keys) > 0 && len(preference.Keys[passwordType]) > 0 {
|
||||
LOG_DEBUG("PASSWORD_PREFERENCE", "Reading %s from preferences", passwordType)
|
||||
return preference.Keys[passwordType]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPassword attempts to get the password from KeyChain/KeyRing, environment variables, or keyboard input.
|
||||
func GetPassword(preference Preference, passwordType string, prompt string,
|
||||
showPassword bool, resetPassword bool) string {
|
||||
passwordID := passwordType
|
||||
|
||||
preferencePassword := GetPasswordFromPreference(preference, passwordType)
|
||||
if preferencePassword != "" {
|
||||
return preferencePassword
|
||||
}
|
||||
|
||||
if preference.Name != "default" {
|
||||
passwordID = preference.Name + "_" + passwordID
|
||||
}
|
||||
|
||||
if resetPassword && !RunInBackground {
|
||||
keyringSet(passwordID, "")
|
||||
} else {
|
||||
password := keyringGet(passwordID)
|
||||
if password != "" {
|
||||
LOG_DEBUG("PASSWORD_KEYCHAIN", "Reading %s from keychain/keyring", passwordType)
|
||||
return password
|
||||
}
|
||||
|
||||
@@ -176,6 +244,7 @@ func GetPassword(preference Preference, passwordType string, prompt string,
|
||||
|
||||
// SavePassword saves the specified password in the keyring/keychain.
|
||||
func SavePassword(preference Preference, passwordType string, password string) {
|
||||
|
||||
if password == "" || RunInBackground {
|
||||
return
|
||||
}
|
||||
@@ -183,6 +252,12 @@ func SavePassword(preference Preference, passwordType string, password string) {
|
||||
if preference.DoNotSavePassword {
|
||||
return
|
||||
}
|
||||
|
||||
// If the password is retrieved from env or preference, don't save it to keyring
|
||||
if GetPasswordFromPreference(preference, passwordType) == password {
|
||||
return
|
||||
}
|
||||
|
||||
passwordID := passwordType
|
||||
if preference.Name != "default" {
|
||||
passwordID = preference.Name + "_" + passwordID
|
||||
@@ -190,54 +265,6 @@ func SavePassword(preference Preference, passwordType string, password string) {
|
||||
keyringSet(passwordID, password)
|
||||
}
|
||||
|
||||
// RemoveEmptyDirectories remove all empty subdirectoreies under top.
|
||||
func RemoveEmptyDirectories(top string) {
|
||||
|
||||
stack := make([]string, 0, 256)
|
||||
|
||||
stack = append(stack, top)
|
||||
|
||||
for len(stack) > 0 {
|
||||
|
||||
dir := stack[len(stack) - 1]
|
||||
stack = stack[:len(stack) - 1]
|
||||
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() && file.Name()[0] != '.' {
|
||||
stack = append(stack, path.Join(dir, file.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
if os.Remove(dir) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dir = path.Dir(dir)
|
||||
for (len(dir) > len(top)) {
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
if os.Remove(dir) != nil {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dir = path.Dir(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The following code was modified from the online article 'Matching Wildcards: An Algorithm', by Kirk J. Krauss,
|
||||
// Dr. Dobb's, August 26, 2008. However, the version in the article doesn't handle cases like matching 'abcccd'
|
||||
// against '*ccd', and the version here fixed that issue.
|
||||
@@ -306,24 +333,61 @@ func matchPattern(text string, pattern string) bool {
|
||||
// either '+' or '-', whereas '-' indicates exclusion and '+' indicates inclusion. Wildcards like '*' and '?' may
|
||||
// appear in the patterns. In case no matching pattern is found, the file will be excluded if all patterns are
|
||||
// include patterns, and included otherwise.
|
||||
func MatchPath(filePath string, patterns [] string) (included bool) {
|
||||
func MatchPath(filePath string, patterns []string) (included bool) {
|
||||
|
||||
var re *regexp.Regexp = nil
|
||||
var found bool
|
||||
var matched bool
|
||||
|
||||
allIncludes := true
|
||||
for _, pattern := range patterns {
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if pattern[0] == '+' {
|
||||
if matchPattern(filePath, pattern[1:]) {
|
||||
LOG_DEBUG("PATTERN_INCLUDE", "%s is included by pattern %s", filePath, pattern)
|
||||
return true
|
||||
}
|
||||
} else if pattern[0] == '-' {
|
||||
allIncludes = false
|
||||
if matchPattern(filePath, pattern[1:]) {
|
||||
LOG_DEBUG("PATTERN_EXCLUDE", "%s is excluded by pattern %s", filePath, pattern)
|
||||
return false
|
||||
}
|
||||
} else if strings.HasPrefix(pattern, "i:") || strings.HasPrefix(pattern, "e:") {
|
||||
if re, found = RegexMap[pattern[2:]]; found {
|
||||
matched = re.MatchString(filePath)
|
||||
} else {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
LOG_ERROR("REGEX_ERROR", "Invalid regex encountered for pattern \"%s\" - %v", pattern[2:], err)
|
||||
}
|
||||
RegexMap[pattern] = re
|
||||
matched = re.MatchString(filePath)
|
||||
}
|
||||
if matched {
|
||||
if strings.HasPrefix(pattern, "i:") {
|
||||
LOG_DEBUG("PATTERN_INCLUDE", "%s is included by pattern %s", filePath, pattern)
|
||||
return true
|
||||
} else {
|
||||
LOG_DEBUG("PATTERN_EXCLUDE", "%s is excluded by pattern %s", filePath, pattern)
|
||||
return false
|
||||
|
||||
}
|
||||
} else {
|
||||
if strings.HasPrefix(pattern, "e:") {
|
||||
allIncludes = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !allIncludes
|
||||
if allIncludes {
|
||||
LOG_DEBUG("PATTERN_EXCLUDE", "%s is excluded", filePath)
|
||||
return false
|
||||
} else {
|
||||
LOG_DEBUG("PATTERN_INCLUDE", "%s is included", filePath)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func joinPath(components ...string) string {
|
||||
@@ -331,56 +395,60 @@ func joinPath(components ...string) string {
|
||||
combinedPath := path.Join(components...)
|
||||
if len(combinedPath) > 257 && runtime.GOOS == "windows" {
|
||||
combinedPath = `\\?\` + filepath.Join(components...)
|
||||
// If the path is on a samba drive we must use the UNC format
|
||||
if strings.HasPrefix(combinedPath, `\\?\\\`) {
|
||||
combinedPath = `\\?\UNC\` + combinedPath[6:]
|
||||
}
|
||||
}
|
||||
return combinedPath
|
||||
}
|
||||
|
||||
func PrettyNumber(number int64) (string) {
|
||||
func PrettyNumber(number int64) string {
|
||||
|
||||
G := int64(1024 * 1024 * 1024)
|
||||
M := int64(1024 * 1024)
|
||||
K := int64(1024)
|
||||
|
||||
if number > 1000 * G {
|
||||
return fmt.Sprintf("%dG", number / G)
|
||||
if number > 1000*G {
|
||||
return fmt.Sprintf("%dG", number/G)
|
||||
} else if number > G {
|
||||
return fmt.Sprintf("%d,%03dM", number / (1000 * M), (number / M) % 1000)
|
||||
return fmt.Sprintf("%d,%03dM", number/(1000*M), (number/M)%1000)
|
||||
} else if number > M {
|
||||
return fmt.Sprintf("%d,%03dK", number / (1000 * K), (number / K) % 1000)
|
||||
return fmt.Sprintf("%d,%03dK", number/(1000*K), (number/K)%1000)
|
||||
} else if number > K {
|
||||
return fmt.Sprintf("%dK", number / K)
|
||||
return fmt.Sprintf("%dK", number/K)
|
||||
} else {
|
||||
return fmt.Sprintf("%d", number)
|
||||
}
|
||||
}
|
||||
|
||||
func PrettySize(size int64) (string) {
|
||||
if size > 1024 * 1024 {
|
||||
return fmt.Sprintf("%.2fM", float64(size) / (1024.0 * 1024.0))
|
||||
func PrettySize(size int64) string {
|
||||
if size > 1024*1024 {
|
||||
return fmt.Sprintf("%.2fM", float64(size)/(1024.0*1024.0))
|
||||
} else if size > 1024 {
|
||||
return fmt.Sprintf("%.0fK", float64(size) / 1024.0)
|
||||
return fmt.Sprintf("%.0fK", float64(size)/1024.0)
|
||||
} else {
|
||||
return fmt.Sprintf("%d", size)
|
||||
}
|
||||
}
|
||||
|
||||
func PrettyTime(seconds int64) (string) {
|
||||
func PrettyTime(seconds int64) string {
|
||||
|
||||
day := int64(3600 * 24)
|
||||
|
||||
if seconds > day * 2 {
|
||||
if seconds > day*2 {
|
||||
return fmt.Sprintf("%d days %02d:%02d:%02d",
|
||||
seconds / day, (seconds % day) / 3600, (seconds % 3600) / 60, seconds % 60)
|
||||
seconds/day, (seconds%day)/3600, (seconds%3600)/60, seconds%60)
|
||||
} else if seconds > day {
|
||||
return fmt.Sprintf("1 day %02d:%02d:%02d", (seconds % day) / 3600, (seconds % 3600) / 60, seconds % 60)
|
||||
return fmt.Sprintf("1 day %02d:%02d:%02d", (seconds%day)/3600, (seconds%3600)/60, seconds%60)
|
||||
} else if seconds > 0 {
|
||||
return fmt.Sprintf("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
|
||||
return fmt.Sprintf("%02d:%02d:%02d", seconds/3600, (seconds%3600)/60, seconds%60)
|
||||
} else {
|
||||
return "n/a"
|
||||
}
|
||||
}
|
||||
|
||||
func AtoSize(sizeString string) (int) {
|
||||
func AtoSize(sizeString string) int {
|
||||
sizeString = strings.ToLower(sizeString)
|
||||
|
||||
sizeRegex := regexp.MustCompile(`^([0-9]+)([mk])?$`)
|
||||
@@ -399,3 +467,10 @@ func AtoSize(sizeString string) (int) {
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
func MinInt(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
// +build !windows
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"bytes"
|
||||
"syscall"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/gilbertchen/xattr"
|
||||
)
|
||||
@@ -31,7 +31,7 @@ func GetOwner(entry *Entry, fileInfo *os.FileInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
func SetOwner(fullPath string, entry *Entry, fileInfo *os.FileInfo) (bool) {
|
||||
func SetOwner(fullPath string, entry *Entry, fileInfo *os.FileInfo) bool {
|
||||
stat, ok := (*fileInfo).Sys().(*syscall.Stat_t)
|
||||
if ok && stat != nil && (int(stat.Uid) != entry.UID || int(stat.Gid) != entry.GID) {
|
||||
if entry.UID != -1 && entry.GID != -1 {
|
||||
@@ -69,7 +69,7 @@ func (entry *Entry) SetAttributesToFile(fullPath string) {
|
||||
newAttribute, found := entry.Attributes[name]
|
||||
if found {
|
||||
oldAttribute, _ := xattr.Getxattr(fullPath, name)
|
||||
if bytes.Equal(oldAttribute, newAttribute) {
|
||||
if !bytes.Equal(oldAttribute, newAttribute) {
|
||||
xattr.Setxattr(fullPath, name, newAttribute)
|
||||
}
|
||||
delete(entry.Attributes, name)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
"bytes"
|
||||
|
||||
crypto_rand "crypto/rand"
|
||||
|
||||
"testing"
|
||||
|
||||
)
|
||||
|
||||
func TestMatchPattern(t *testing.T) {
|
||||
@@ -21,70 +20,70 @@ func TestMatchPattern(t *testing.T) {
|
||||
// Test cases were copied from Matching Wildcards: An Empirical Way to Tame an Algorithm
|
||||
// By Kirk J. Krauss, October 07, 2014
|
||||
|
||||
DATA := [] struct {
|
||||
DATA := []struct {
|
||||
text string
|
||||
pattern string
|
||||
matched bool
|
||||
} {
|
||||
}{
|
||||
// Cases with repeating character sequences.
|
||||
{ "abcccd", "*ccd", true },
|
||||
{ "mississipissippi", "*issip*ss*", true },
|
||||
{ "xxxx*zzzzzzzzy*f", "xxxx*zzy*fffff", false },
|
||||
{ "xxxx*zzzzzzzzy*f", "xxx*zzy*f", true },
|
||||
{ "xxxxzzzzzzzzyf", "xxxx*zzy*fffff", false },
|
||||
{ "xxxxzzzzzzzzyf", "xxxx*zzy*f", true },
|
||||
{ "xyxyxyzyxyz", "xy*z*xyz", true },
|
||||
{ "mississippi", "*sip*", true },
|
||||
{ "xyxyxyxyz", "xy*xyz", true },
|
||||
{ "mississippi", "mi*sip*", true },
|
||||
{ "ababac", "*abac*", true },
|
||||
{ "ababac", "*abac*", true },
|
||||
{ "aaazz", "a*zz*", true },
|
||||
{ "a12b12", "*12*23", false },
|
||||
{ "a12b12", "a12b", false },
|
||||
{ "a12b12", "*12*12*", true },
|
||||
{"abcccd", "*ccd", true},
|
||||
{"mississipissippi", "*issip*ss*", true},
|
||||
{"xxxx*zzzzzzzzy*f", "xxxx*zzy*fffff", false},
|
||||
{"xxxx*zzzzzzzzy*f", "xxx*zzy*f", true},
|
||||
{"xxxxzzzzzzzzyf", "xxxx*zzy*fffff", false},
|
||||
{"xxxxzzzzzzzzyf", "xxxx*zzy*f", true},
|
||||
{"xyxyxyzyxyz", "xy*z*xyz", true},
|
||||
{"mississippi", "*sip*", true},
|
||||
{"xyxyxyxyz", "xy*xyz", true},
|
||||
{"mississippi", "mi*sip*", true},
|
||||
{"ababac", "*abac*", true},
|
||||
{"ababac", "*abac*", true},
|
||||
{"aaazz", "a*zz*", true},
|
||||
{"a12b12", "*12*23", false},
|
||||
{"a12b12", "a12b", false},
|
||||
{"a12b12", "*12*12*", true},
|
||||
|
||||
// More double wildcard scenarios.
|
||||
{ "XYXYXYZYXYz", "XY*Z*XYz", true },
|
||||
{ "missisSIPpi", "*SIP*", true },
|
||||
{ "mississipPI", "*issip*PI", true },
|
||||
{ "xyxyxyxyz", "xy*xyz", true },
|
||||
{ "miSsissippi", "mi*sip*", true },
|
||||
{ "miSsissippi", "mi*Sip*", false },
|
||||
{ "abAbac", "*Abac*", true },
|
||||
{ "abAbac", "*Abac*", true },
|
||||
{ "aAazz", "a*zz*", true },
|
||||
{ "A12b12", "*12*23", false },
|
||||
{ "a12B12", "*12*12*", true },
|
||||
{ "oWn", "*oWn*", true },
|
||||
{"XYXYXYZYXYz", "XY*Z*XYz", true},
|
||||
{"missisSIPpi", "*SIP*", true},
|
||||
{"mississipPI", "*issip*PI", true},
|
||||
{"xyxyxyxyz", "xy*xyz", true},
|
||||
{"miSsissippi", "mi*sip*", true},
|
||||
{"miSsissippi", "mi*Sip*", false},
|
||||
{"abAbac", "*Abac*", true},
|
||||
{"abAbac", "*Abac*", true},
|
||||
{"aAazz", "a*zz*", true},
|
||||
{"A12b12", "*12*23", false},
|
||||
{"a12B12", "*12*12*", true},
|
||||
{"oWn", "*oWn*", true},
|
||||
|
||||
// Completely tame (no wildcards) cases.
|
||||
{ "bLah", "bLah", true },
|
||||
{ "bLah", "bLaH", false },
|
||||
{"bLah", "bLah", true},
|
||||
{"bLah", "bLaH", false},
|
||||
|
||||
// Simple mixed wildcard tests suggested by IBMer Marlin Deckert.
|
||||
{ "a", "*?", true },
|
||||
{ "ab", "*?", true },
|
||||
{ "abc", "*?", true },
|
||||
{"a", "*?", true},
|
||||
{"ab", "*?", true},
|
||||
{"abc", "*?", true},
|
||||
|
||||
// More mixed wildcard tests including coverage for false positives.
|
||||
{ "a", "??", false },
|
||||
{ "ab", "?*?", true },
|
||||
{ "ab", "*?*?*", true },
|
||||
{ "abc", "?*?*?", true },
|
||||
{ "abc", "?*?*&?", false },
|
||||
{ "abcd", "?b*??", true },
|
||||
{ "abcd", "?a*??", false },
|
||||
{ "abcd", "?*?c?", true },
|
||||
{ "abcd", "?*?d?", false },
|
||||
{ "abcde", "?*b*?*d*?", true },
|
||||
{"a", "??", false},
|
||||
{"ab", "?*?", true},
|
||||
{"ab", "*?*?*", true},
|
||||
{"abc", "?*?*?", true},
|
||||
{"abc", "?*?*&?", false},
|
||||
{"abcd", "?b*??", true},
|
||||
{"abcd", "?a*??", false},
|
||||
{"abcd", "?*?c?", true},
|
||||
{"abcd", "?*?d?", false},
|
||||
{"abcde", "?*b*?*d*?", true},
|
||||
|
||||
// Single-character-match cases.
|
||||
{ "bLah", "bL?h", true },
|
||||
{ "bLaaa", "bLa?", false },
|
||||
{ "bLah", "bLa?", true },
|
||||
{ "bLaH", "?Lah", false },
|
||||
{ "bLaH", "?LaH", true },
|
||||
{"bLah", "bL?h", true},
|
||||
{"bLaaa", "bLa?", false},
|
||||
{"bLah", "bLa?", true},
|
||||
{"bLaH", "?Lah", false},
|
||||
{"bLaH", "?LaH", true},
|
||||
}
|
||||
|
||||
for _, data := range DATA {
|
||||
@@ -96,7 +95,7 @@ func TestMatchPattern(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
content := make([]byte, 100 * 1024)
|
||||
content := make([]byte, 100*1024)
|
||||
_, err := crypto_rand.Read(content)
|
||||
if err != nil {
|
||||
t.Errorf("Error generating random content: %v", err)
|
||||
@@ -113,7 +112,7 @@ func TestRateLimit(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if int(n) != len(content) {
|
||||
t.Errorf("Wrote %s bytes instead of %s", n, len(content))
|
||||
t.Errorf("Wrote %d bytes instead of %d", n, len(content))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,7 +127,7 @@ func TestRateLimit(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if int(n) != len(content) {
|
||||
t.Errorf("Copied %s bytes instead of %s", n, len(content))
|
||||
t.Errorf("Copied %d bytes instead of %d", n, len(content))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Licensed under the Fair Source License 0.9 (https://fair.io/)
|
||||
// User Limitation: 5 users
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -36,6 +36,7 @@ type reparseDataBuffer struct {
|
||||
// GenericReparseBuffer
|
||||
reparseBuffer byte
|
||||
}
|
||||
|
||||
const (
|
||||
FSCTL_GET_REPARSE_POINT = 0x900A8
|
||||
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16 * 1024
|
||||
@@ -75,17 +76,17 @@ func Readlink(path string) (isRegular bool, s string, err error) {
|
||||
data := (*symbolicLinkReparseBuffer)(unsafe.Pointer(&rdb.reparseBuffer))
|
||||
p := (*[0xffff]uint16)(unsafe.Pointer(&data.PathBuffer[0]))
|
||||
if data.PrintNameLength > 0 {
|
||||
s = syscall.UTF16ToString(p[data.PrintNameOffset/2 : (data.PrintNameLength + data.PrintNameOffset)/2])
|
||||
s = syscall.UTF16ToString(p[data.PrintNameOffset/2 : (data.PrintNameLength+data.PrintNameOffset)/2])
|
||||
} else {
|
||||
s = syscall.UTF16ToString(p[data.SubstituteNameOffset/2 : (data.SubstituteNameLength + data.SubstituteNameOffset)/2])
|
||||
s = syscall.UTF16ToString(p[data.SubstituteNameOffset/2 : (data.SubstituteNameLength+data.SubstituteNameOffset)/2])
|
||||
}
|
||||
case IO_REPARSE_TAG_MOUNT_POINT:
|
||||
data := (*mountPointReparseBuffer)(unsafe.Pointer(&rdb.reparseBuffer))
|
||||
p := (*[0xffff]uint16)(unsafe.Pointer(&data.PathBuffer[0]))
|
||||
if data.PrintNameLength > 0 {
|
||||
s = syscall.UTF16ToString(p[data.PrintNameOffset/2 : (data.PrintNameLength + data.PrintNameOffset)/2])
|
||||
s = syscall.UTF16ToString(p[data.PrintNameOffset/2 : (data.PrintNameLength+data.PrintNameOffset)/2])
|
||||
} else {
|
||||
s = syscall.UTF16ToString(p[data.SubstituteNameOffset/2 : (data.SubstituteNameLength + data.SubstituteNameOffset)/2])
|
||||
s = syscall.UTF16ToString(p[data.SubstituteNameOffset/2 : (data.SubstituteNameLength+data.SubstituteNameOffset)/2])
|
||||
}
|
||||
case IO_REPARSE_TAG_DEDUP:
|
||||
return true, "", nil
|
||||
@@ -103,7 +104,7 @@ func GetOwner(entry *Entry, fileInfo *os.FileInfo) {
|
||||
entry.GID = -1
|
||||
}
|
||||
|
||||
func SetOwner(fullPath string, entry *Entry, fileInfo *os.FileInfo) (bool) {
|
||||
func SetOwner(fullPath string, entry *Entry, fileInfo *os.FileInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
192
src/duplicacy_wasabistorage.go
Normal file
192
src/duplicacy_wasabistorage.go
Normal file
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// Storage module for Wasabi (https://www.wasabi.com)
|
||||
//
|
||||
|
||||
// Wasabi is nominally compatible with AWS S3, but the copy-and-delete
|
||||
// method used for renaming objects creates additional expense under
|
||||
// Wasabi's billing system. This module is a pass-through to the
|
||||
// existing S3 module for everything other than that one operation.
|
||||
//
|
||||
// This module copyright 2017 Mark Feit (https://github.com/markfeit)
|
||||
// and may be distributed under the same terms as Duplicacy.
|
||||
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WasabiStorage struct {
|
||||
StorageBase
|
||||
|
||||
s3 *S3Storage
|
||||
region string
|
||||
endpoint string
|
||||
bucket string
|
||||
storageDir string
|
||||
key string
|
||||
secret string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// See the Storage interface in duplicacy_storage.go for function
|
||||
// descriptions.
|
||||
|
||||
func CreateWasabiStorage(
|
||||
regionName string, endpoint string,
|
||||
bucketName string, storageDir string,
|
||||
accessKey string, secretKey string,
|
||||
threads int,
|
||||
) (storage *WasabiStorage, err error) {
|
||||
|
||||
s3storage, error := CreateS3Storage(regionName, endpoint, bucketName,
|
||||
storageDir, accessKey, secretKey, threads,
|
||||
true, // isSSLSupported
|
||||
false, // isMinioCompatible
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, error
|
||||
}
|
||||
|
||||
wasabi := &WasabiStorage{
|
||||
|
||||
// Pass-through to existing S3 module
|
||||
s3: s3storage,
|
||||
|
||||
// Local copies required for renaming
|
||||
region: regionName,
|
||||
endpoint: endpoint,
|
||||
bucket: bucketName,
|
||||
storageDir: storageDir,
|
||||
key: accessKey,
|
||||
secret: secretKey,
|
||||
client: &http.Client{},
|
||||
}
|
||||
|
||||
wasabi.DerivedStorage = wasabi
|
||||
wasabi.SetDefaultNestingLevels([]int{0}, 0)
|
||||
|
||||
return wasabi, nil
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) ListFiles(
|
||||
threadIndex int, dir string,
|
||||
) (files []string, sizes []int64, err error) {
|
||||
return storage.s3.ListFiles(threadIndex, dir)
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) DeleteFile(
|
||||
threadIndex int, filePath string,
|
||||
) (err error) {
|
||||
return storage.s3.DeleteFile(threadIndex, filePath)
|
||||
|
||||
}
|
||||
|
||||
// This is a lightweight implementation of a call to Wasabi for a
|
||||
// rename. It's designed to get the job done with as few dependencies
|
||||
// on other packages as possible rather than being somethng
|
||||
// general-purpose and reusable.
|
||||
func (storage *WasabiStorage) MoveFile(
|
||||
threadIndex int, from string, to string,
|
||||
) (err error) {
|
||||
|
||||
var from_path string
|
||||
// The from path includes the bucket. Take care not to include an empty storageDir
|
||||
// string as Wasabi's backend will return 404 on URLs with double slashes.
|
||||
if (storage.storageDir == "") {
|
||||
from_path = fmt.Sprintf("/%s/%s", storage.bucket, from)
|
||||
} else {
|
||||
from_path = fmt.Sprintf("/%s/%s/%s", storage.bucket, storage.storageDir, from)
|
||||
}
|
||||
|
||||
object := fmt.Sprintf("https://%s@%s%s",
|
||||
storage.region, storage.endpoint, from_path)
|
||||
|
||||
// The object's new name is relative to the top of the bucket.
|
||||
new_name := fmt.Sprintf("%s/%s", storage.storageDir, to)
|
||||
|
||||
timestamp := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
signing_string := fmt.Sprintf("MOVE\n\n\n%s\n%s", timestamp, from_path)
|
||||
|
||||
signer := hmac.New(sha1.New, []byte(storage.secret))
|
||||
signer.Write([]byte(signing_string))
|
||||
|
||||
signature := base64.StdEncoding.EncodeToString(signer.Sum(nil))
|
||||
|
||||
authorization := fmt.Sprintf("AWS %s:%s", storage.key, signature)
|
||||
|
||||
request, error := http.NewRequest("MOVE", object, nil)
|
||||
if error != nil {
|
||||
return error
|
||||
}
|
||||
request.Header.Add("Authorization", authorization)
|
||||
request.Header.Add("Date", timestamp)
|
||||
request.Header.Add("Destination", new_name)
|
||||
request.Header.Add("Host", storage.endpoint)
|
||||
request.Header.Add("Overwrite", "true")
|
||||
|
||||
response, error := storage.client.Do(request)
|
||||
if error != nil {
|
||||
return error
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return errors.New(response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) CreateDirectory(
|
||||
threadIndex int, dir string,
|
||||
) (err error) {
|
||||
return storage.s3.CreateDirectory(threadIndex, dir)
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) GetFileInfo(
|
||||
threadIndex int, filePath string,
|
||||
) (exist bool, isDir bool, size int64, err error) {
|
||||
return storage.s3.GetFileInfo(threadIndex, filePath)
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) DownloadFile(
|
||||
threadIndex int, filePath string, chunk *Chunk,
|
||||
) (err error) {
|
||||
return storage.s3.DownloadFile(threadIndex, filePath, chunk)
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) UploadFile(
|
||||
threadIndex int, filePath string, content []byte,
|
||||
) (err error) {
|
||||
return storage.s3.UploadFile(threadIndex, filePath, content)
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) IsCacheNeeded() bool {
|
||||
return storage.s3.IsCacheNeeded()
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) IsMoveFileImplemented() bool {
|
||||
// This is implemented locally since S3 does a copy and delete
|
||||
return true
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) IsStrongConsistent() bool {
|
||||
// Wasabi has it, S3 doesn't.
|
||||
return true
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) IsFastListing() bool {
|
||||
return storage.s3.IsFastListing()
|
||||
}
|
||||
|
||||
func (storage *WasabiStorage) EnableTestMode() {
|
||||
}
|
||||
450
src/duplicacy_webdavstorage.go
Normal file
450
src/duplicacy_webdavstorage.go
Normal file
@@ -0,0 +1,450 @@
|
||||
// Copyright (c) Acrosync LLC. All rights reserved.
|
||||
// Free for personal use and commercial trial
|
||||
// Commercial use requires per-user licenses available from https://duplicacy.com
|
||||
//
|
||||
//
|
||||
// This storage backend is based on the work by Yuri Karamani from https://github.com/karamani/webdavclnt,
|
||||
// released under the MIT license.
|
||||
//
|
||||
package duplicacy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
//"net/http/httputil"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WebDAVStorage struct {
|
||||
StorageBase
|
||||
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
storageDir string
|
||||
useHTTP bool
|
||||
|
||||
client *http.Client
|
||||
threads int
|
||||
directoryCache map[string]int // stores directories known to exist by this backend
|
||||
directoryCacheLock sync.Mutex // lock for accessing directoryCache
|
||||
}
|
||||
|
||||
var (
|
||||
errWebDAVAuthorizationFailure = errors.New("Authentication failed")
|
||||
errWebDAVMovedPermanently = errors.New("Moved permanently")
|
||||
errWebDAVNotExist = errors.New("Path does not exist")
|
||||
errWebDAVMaximumBackoff = errors.New("Maximum backoff reached")
|
||||
errWebDAVMethodNotAllowed = errors.New("Method not allowed")
|
||||
)
|
||||
|
||||
func CreateWebDAVStorage(host string, port int, username string, password string, storageDir string, useHTTP bool, threads int) (storage *WebDAVStorage, err error) {
|
||||
if storageDir[len(storageDir)-1] != '/' {
|
||||
storageDir += "/"
|
||||
}
|
||||
|
||||
storage = &WebDAVStorage{
|
||||
host: host,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
storageDir: "",
|
||||
useHTTP: false,
|
||||
|
||||
client: http.DefaultClient,
|
||||
threads: threads,
|
||||
directoryCache: make(map[string]int),
|
||||
}
|
||||
|
||||
// Make sure it doesn't follow redirect
|
||||
storage.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
exist, isDir, _, err := storage.GetFileInfo(0, storageDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return nil, fmt.Errorf("Storage path %s does not exist", storageDir)
|
||||
}
|
||||
if !isDir {
|
||||
return nil, fmt.Errorf("Storage path %s is not a directory", storageDir)
|
||||
}
|
||||
storage.storageDir = storageDir
|
||||
|
||||
for _, dir := range []string{"snapshots", "chunks"} {
|
||||
storage.CreateDirectory(0, dir)
|
||||
}
|
||||
|
||||
storage.DerivedStorage = storage
|
||||
storage.SetDefaultNestingLevels([]int{0}, 0)
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func (storage *WebDAVStorage) createConnectionString(uri string) string {
|
||||
|
||||
url := storage.host
|
||||
|
||||
if storage.useHTTP {
|
||||
url = "http://" + url
|
||||
} else {
|
||||
url = "https://" + url
|
||||
}
|
||||
|
||||
if storage.port > 0 {
|
||||
url += fmt.Sprintf(":%d", storage.port)
|
||||
}
|
||||
return url + "/" + storage.storageDir + uri
|
||||
}
|
||||
|
||||
func (storage *WebDAVStorage) retry(backoff int) int {
|
||||
delay := rand.Intn(backoff*500) + backoff*500
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
backoff *= 2
|
||||
return backoff
|
||||
}
|
||||
|
||||
func (storage *WebDAVStorage) sendRequest(method string, uri string, depth int, data []byte) (io.ReadCloser, http.Header, error) {
|
||||
|
||||
backoff := 1
|
||||
for i := 0; i < 8; i++ {
|
||||
|
||||
var dataReader io.Reader
|
||||
headers := make(map[string]string)
|
||||
if method == "PROPFIND" {
|
||||
headers["Content-Type"] = "application/xml"
|
||||
headers["Depth"] = fmt.Sprintf("%d", depth)
|
||||
dataReader = bytes.NewReader(data)
|
||||
} else if method == "PUT" {
|
||||
headers["Content-Type"] = "application/octet-stream"
|
||||
dataReader = CreateRateLimitedReader(data, storage.UploadRateLimit/storage.threads)
|
||||
} else if method == "MOVE" {
|
||||
headers["Destination"] = storage.createConnectionString(string(data))
|
||||
headers["Content-Type"] = "application/octet-stream"
|
||||
dataReader = bytes.NewReader([]byte(""))
|
||||
} else {
|
||||
headers["Content-Type"] = "application/octet-stream"
|
||||
dataReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(method, storage.createConnectionString(uri), dataReader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if len(storage.username) > 0 {
|
||||
request.SetBasicAuth(storage.username, storage.password)
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
|
||||
//requestDump, err := httputil.DumpRequest(request, true)
|
||||
//LOG_INFO("debug", "Request: %s", requestDump)
|
||||
|
||||
response, err := storage.client.Do(request)
|
||||
if err != nil {
|
||||
LOG_TRACE("WEBDAV_RETRY", "URL request '%s %s' returned an error (%v)", method, uri, err)
|
||||
backoff = storage.retry(backoff)
|
||||
continue
|
||||
}
|
||||
|
||||
if response.StatusCode < 300 {
|
||||
return response.Body, response.Header, nil
|
||||
}
|
||||
|
||||
if response.StatusCode == 301 {
|
||||
return nil, nil, errWebDAVMovedPermanently
|
||||
}
|
||||
|
||||
response.Body.Close()
|
||||
if response.StatusCode == 404 {
|
||||
// Retry if it is UPLOAD, otherwise return immediately
|
||||
if method != "PUT" {
|
||||
return nil, nil, errWebDAVNotExist
|
||||
}
|
||||
} else if response.StatusCode == 405 {
|
||||
return nil, nil, errWebDAVMethodNotAllowed
|
||||
}
|
||||
LOG_INFO("WEBDAV_RETRY", "URL request '%s %s' returned status code %d", method, uri, response.StatusCode)
|
||||
backoff = storage.retry(backoff)
|
||||
}
|
||||
return nil, nil, errWebDAVMaximumBackoff
|
||||
}
|
||||
|
||||
type WebDAVProperties map[string]string
|
||||
|
||||
type WebDAVPropValue struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
Value string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type WebDAVProp struct {
|
||||
PropList []WebDAVPropValue `xml:",any"`
|
||||
}
|
||||
|
||||
type WebDAVPropStat struct {
|
||||
Prop *WebDAVProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type WebDAVResponse struct {
|
||||
Href string `xml:"href"`
|
||||
PropStat *WebDAVPropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type WebDAVMultiStatus struct {
|
||||
Responses []WebDAVResponse `xml:"response"`
|
||||
}
|
||||
|
||||
func (storage *WebDAVStorage) getProperties(uri string, depth int, properties ...string) (map[string]WebDAVProperties, error) {
|
||||
|
||||
propfind := "<prop>"
|
||||
for _, p := range properties {
|
||||
propfind += fmt.Sprintf("<%s/>", p)
|
||||
}
|
||||
propfind += "</prop>"
|
||||
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8" ?><propfind xmlns="DAV:">%s</propfind>`, propfind)
|
||||
|
||||
readCloser, _, err := storage.sendRequest("PROPFIND", uri, depth, []byte(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer readCloser.Close()
|
||||
content, err := ioutil.ReadAll(readCloser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
object := WebDAVMultiStatus{}
|
||||
err = xml.Unmarshal(content, &object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if object.Responses == nil || len(object.Responses) == 0 {
|
||||
return nil, errors.New("no WebDAV responses")
|
||||
}
|
||||
|
||||
responses := make(map[string]WebDAVProperties)
|
||||
|
||||
for _, responseTag := range object.Responses {
|
||||
|
||||
if responseTag.PropStat == nil || responseTag.PropStat.Prop == nil || responseTag.PropStat.Prop.PropList == nil {
|
||||
return nil, errors.New("no WebDAV properties")
|
||||
}
|
||||
|
||||
properties := make(WebDAVProperties)
|
||||
for _, prop := range responseTag.PropStat.Prop.PropList {
|
||||
properties[prop.XMLName.Local] = prop.Value
|
||||
}
|
||||
|
||||
responseKey := responseTag.Href
|
||||
responses[responseKey] = properties
|
||||
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// ListFiles return the list of files and subdirectories under 'dir'. A subdirectories returned must have a trailing '/', with
|
||||
// a size of 0. If 'dir' is 'snapshots', only subdirectories will be returned. If 'dir' is 'snapshots/repository_id', then only
|
||||
// files will be returned. If 'dir' is 'chunks', the implementation can return the list either recusively or non-recusively.
|
||||
func (storage *WebDAVStorage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) {
|
||||
if dir[len(dir)-1] != '/' {
|
||||
dir += "/"
|
||||
}
|
||||
properties, err := storage.getProperties(dir, 1, "getcontentlength", "resourcetype")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
prefixLength := len(storage.storageDir) + len(dir) + 1
|
||||
|
||||
for file, m := range properties {
|
||||
if len(file) <= prefixLength {
|
||||
continue
|
||||
}
|
||||
|
||||
isDir := false
|
||||
size := 0
|
||||
if resourceType, exist := m["resourcetype"]; exist && strings.Contains(resourceType, "collection") {
|
||||
isDir = true
|
||||
} else if length, exist := m["getcontentlength"]; exist {
|
||||
if length == "" {
|
||||
isDir = true
|
||||
} else {
|
||||
size, _ = strconv.Atoi(length)
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isDir {
|
||||
if dir != "snapshots/" {
|
||||
files = append(files, file[prefixLength:])
|
||||
sizes = append(sizes, int64(size))
|
||||
}
|
||||
} else {
|
||||
// This is a dir
|
||||
file := file[prefixLength:]
|
||||
if file[len(file)-1] != '/' {
|
||||
file += "/"
|
||||
}
|
||||
files = append(files, file)
|
||||
sizes = append(sizes, int64(0))
|
||||
}
|
||||
}
|
||||
|
||||
return files, sizes, nil
|
||||
}
|
||||
|
||||
// GetFileInfo returns the information about the file or directory at 'filePath'.
|
||||
func (storage *WebDAVStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) {
|
||||
properties, err := storage.getProperties(filePath, 0, "getcontentlength", "resourcetype")
|
||||
if err != nil {
|
||||
if err == errWebDAVNotExist {
|
||||
return false, false, 0, nil
|
||||
}
|
||||
if err == errWebDAVMovedPermanently {
|
||||
// This must be a directory
|
||||
return true, true, 0, nil
|
||||
}
|
||||
return false, false, 0, err
|
||||
}
|
||||
|
||||
if m, exist := properties["/" + storage.storageDir + filePath]; !exist {
|
||||
return false, false, 0, nil
|
||||
} else if resourceType, exist := m["resourcetype"]; exist && strings.Contains(resourceType, "collection") {
|
||||
return true, true, 0, nil
|
||||
} else if length, exist := m["getcontentlength"]; exist && length != ""{
|
||||
value, _ := strconv.Atoi(length)
|
||||
return true, false, int64(value), nil
|
||||
} else {
|
||||
return true, true, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFile deletes the file or directory at 'filePath'.
|
||||
func (storage *WebDAVStorage) DeleteFile(threadIndex int, filePath string) (err error) {
|
||||
readCloser, _, err := storage.sendRequest("DELETE", filePath, 0, []byte(""))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readCloser.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveFile renames the file.
|
||||
func (storage *WebDAVStorage) MoveFile(threadIndex int, from string, to string) (err error) {
|
||||
readCloser, _, err := storage.sendRequest("MOVE", from, 0, []byte(to))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readCloser.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// createParentDirectory creates the parent directory if it doesn't exist in the cache
|
||||
func (storage *WebDAVStorage) createParentDirectory(threadIndex int, dir string) (err error) {
|
||||
|
||||
found := strings.LastIndex(dir, "/")
|
||||
if found == -1 {
|
||||
return nil
|
||||
}
|
||||
parent := dir[:found]
|
||||
|
||||
storage.directoryCacheLock.Lock()
|
||||
_, exist := storage.directoryCache[parent]
|
||||
storage.directoryCacheLock.Unlock()
|
||||
|
||||
if exist {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = storage.CreateDirectory(threadIndex, parent)
|
||||
if err == nil {
|
||||
storage.directoryCacheLock.Lock()
|
||||
storage.directoryCache[parent] = 1
|
||||
storage.directoryCacheLock.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateDirectory creates a new directory.
|
||||
func (storage *WebDAVStorage) CreateDirectory(threadIndex int, dir string) (err error) {
|
||||
for dir != "" && dir[len(dir)-1] == '/' {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If there is an error in creating the parent directory, proceed anyway
|
||||
storage.createParentDirectory(threadIndex, dir)
|
||||
|
||||
readCloser, _, err := storage.sendRequest("MKCOL", dir, 0, []byte(""))
|
||||
if err != nil {
|
||||
if err == errWebDAVMethodNotAllowed || err == errWebDAVMovedPermanently {
|
||||
// We simply ignore these errors and assume that the directory already exists
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
readCloser.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile reads the file at 'filePath' into the chunk.
|
||||
func (storage *WebDAVStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) {
|
||||
readCloser, _, err := storage.sendRequest("GET", filePath, 0, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.threads)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFile writes 'content' to the file at 'filePath'.
|
||||
func (storage *WebDAVStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) {
|
||||
|
||||
// If there is an error in creating the parent directory, proceed anyway
|
||||
storage.createParentDirectory(threadIndex, filePath)
|
||||
|
||||
readCloser, _, err := storage.sendRequest("PUT", filePath, 0, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readCloser.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when
|
||||
// managing snapshots.
|
||||
func (storage *WebDAVStorage) IsCacheNeeded() bool { return true }
|
||||
|
||||
// If the 'MoveFile' method is implemented.
|
||||
func (storage *WebDAVStorage) IsMoveFileImplemented() bool { return true }
|
||||
|
||||
// If the storage can guarantee strong consistency.
|
||||
func (storage *WebDAVStorage) IsStrongConsistent() bool { return false }
|
||||
|
||||
// If the storage supports fast listing of files names.
|
||||
func (storage *WebDAVStorage) IsFastListing() bool { return false }
|
||||
|
||||
// Enable the test mode.
|
||||
func (storage *WebDAVStorage) EnableTestMode() {}
|
||||
Reference in New Issue
Block a user