@better-forge/backup 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +12 -12
- package/package.json +57 -57
- package/src/archive.ts +114 -114
- package/src/cli.ts +62 -62
- package/src/index.ts +10 -10
- package/src/restore.ts +111 -111
- package/src/routes.ts +115 -115
- package/src/runner.ts +45 -45
- package/src/scheduler.ts +39 -39
- package/src/service.ts +250 -250
- package/src/worker.ts +18 -18
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# @better-forge/backup
|
|
2
|
-
|
|
3
|
-
Backup, restore and verification of a SQLite database and the files around it.
|
|
4
|
-
|
|
5
|
-
- `createBackupService` writes a consistent snapshot of the database (`VACUUM INTO`, integrity-checked) plus the files the application adds, into one checksummed archive, and verifies, lists, prunes and locks backups. The schedule and the last status are kept as files beside the backups.
|
|
6
|
-
- `createRestore` restores an archive over a stopped application, takes a safety backup first and puts every replaced file back if the restore fails half way. The application supplies the files around the database, its database handle and the checks that may refuse a backup.
|
|
7
|
-
- `createBackupRunner` and `@better-forge/backup/worker` run a backup or a verification on a worker thread.
|
|
8
|
-
- `createBackupRoutes` makes the admin routes (list, create, schedule, download, delete, verify an uploaded file) and `createBackupScheduler` runs backups on the configured schedule.
|
|
9
|
-
- `runBackupCli` and `runRestoreCli` are the command lines of `backup`, `backup --verify` and `restore`; the application keeps its boot (environment, database) in the entry file.
|
|
10
|
-
- `createArchiveFormat` is the archive format on its own; the `magic` bytes identify the archives of one product.
|
|
11
|
-
|
|
12
|
-
Depends on `@better-forge/audit` (the routes) and `node-cron` (the scheduler); the service itself needs only Node (`node:sqlite`).
|
|
1
|
+
# @better-forge/backup
|
|
2
|
+
|
|
3
|
+
Backup, restore and verification of a SQLite database and the files around it.
|
|
4
|
+
|
|
5
|
+
- `createBackupService` writes a consistent snapshot of the database (`VACUUM INTO`, integrity-checked) plus the files the application adds, into one checksummed archive, and verifies, lists, prunes and locks backups. The schedule and the last status are kept as files beside the backups.
|
|
6
|
+
- `createRestore` restores an archive over a stopped application, takes a safety backup first and puts every replaced file back if the restore fails half way. The application supplies the files around the database, its database handle and the checks that may refuse a backup.
|
|
7
|
+
- `createBackupRunner` and `@better-forge/backup/worker` run a backup or a verification on a worker thread.
|
|
8
|
+
- `createBackupRoutes` makes the admin routes (list, create, schedule, download, delete, verify an uploaded file) and `createBackupScheduler` runs backups on the configured schedule.
|
|
9
|
+
- `runBackupCli` and `runRestoreCli` are the command lines of `backup`, `backup --verify` and `restore`; the application keeps its boot (environment, database) in the entry file.
|
|
10
|
+
- `createArchiveFormat` is the archive format on its own; the `magic` bytes identify the archives of one product.
|
|
11
|
+
|
|
12
|
+
Depends on `@better-forge/audit` (the routes) and `node-cron` (the scheduler); the service itself needs only Node (`node:sqlite`).
|
package/package.json
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@better-forge/backup",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Backup, restore, verification, scheduler and CLI.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
-
"directory": "packages/backup"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/backup#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
-
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"type": "module",
|
|
19
|
-
"main": "./dist/index.js",
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"exports": {
|
|
22
|
-
".": {
|
|
23
|
-
"types": "./dist/index.d.ts",
|
|
24
|
-
"import": "./dist/index.js"
|
|
25
|
-
},
|
|
26
|
-
"./worker": {
|
|
27
|
-
"types": "./dist/worker.d.ts",
|
|
28
|
-
"import": "./dist/worker.js"
|
|
29
|
-
},
|
|
30
|
-
"./package.json": "./package.json"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"dist",
|
|
34
|
-
"src",
|
|
35
|
-
"!src/**/*.test.*"
|
|
36
|
-
],
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsc -p tsconfig.json",
|
|
39
|
-
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
40
|
-
},
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"fastify": "^5.3.2"
|
|
43
|
-
},
|
|
44
|
-
"devDependencies": {
|
|
45
|
-
"@types/node": "^26.6.2",
|
|
46
|
-
"fastify": "^5.3.2",
|
|
47
|
-
"typescript": "^5.7.3"
|
|
48
|
-
},
|
|
49
|
-
"dependencies": {
|
|
50
|
-
"node-cron": "^4.6.0",
|
|
51
|
-
"@better-forge/audit": "0.1.
|
|
52
|
-
"@better-forge/db": "0.1.
|
|
53
|
-
},
|
|
54
|
-
"engines": {
|
|
55
|
-
"node": ">=26.10.0"
|
|
56
|
-
}
|
|
57
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-forge/backup",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Backup, restore, verification, scheduler and CLI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/backup"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/backup#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./worker": {
|
|
27
|
+
"types": "./dist/worker.d.ts",
|
|
28
|
+
"import": "./dist/worker.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"!src/**/*.test.*"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.json",
|
|
39
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"fastify": "^5.3.2"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^26.6.2",
|
|
46
|
+
"fastify": "^5.3.2",
|
|
47
|
+
"typescript": "^5.7.3"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"node-cron": "^4.6.0",
|
|
51
|
+
"@better-forge/audit": "0.1.1",
|
|
52
|
+
"@better-forge/db": "0.1.1"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=26.10.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/archive.ts
CHANGED
|
@@ -1,114 +1,114 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import crypto from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
const HASH_SIZE = 32
|
|
6
|
-
|
|
7
|
-
export interface ArchiveEntry { name: string; source: string }
|
|
8
|
-
|
|
9
|
-
function safeName(name: string): string {
|
|
10
|
-
const normalized = name.replaceAll('\\', '/')
|
|
11
|
-
if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
|
|
12
|
-
throw new Error(`Unsafe archive path: ${name}`)
|
|
13
|
-
}
|
|
14
|
-
return normalized
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function sha256(file: string): Buffer {
|
|
18
|
-
const hash = crypto.createHash('sha256')
|
|
19
|
-
const fd = fs.openSync(file, 'r')
|
|
20
|
-
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
21
|
-
try {
|
|
22
|
-
let count = 0
|
|
23
|
-
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count))
|
|
24
|
-
} finally { fs.closeSync(fd) }
|
|
25
|
-
return hash.digest()
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function copyBytes(input: number, output: number, size: number, hash?: crypto.Hash): void {
|
|
29
|
-
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
30
|
-
let remaining = size
|
|
31
|
-
while (remaining > 0) {
|
|
32
|
-
const count = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null)
|
|
33
|
-
if (count === 0) throw new Error('Unexpected end of backup archive')
|
|
34
|
-
const chunk = buffer.subarray(0, count)
|
|
35
|
-
if (hash) hash.update(chunk)
|
|
36
|
-
fs.writeSync(output, chunk)
|
|
37
|
-
remaining -= count
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function readExact(fd: number, size: number): Buffer {
|
|
42
|
-
const buffer = Buffer.alloc(size)
|
|
43
|
-
let offset = 0
|
|
44
|
-
while (offset < size) {
|
|
45
|
-
const count = fs.readSync(fd, buffer, offset, size - offset, null)
|
|
46
|
-
if (!count) throw new Error('Truncated backup archive')
|
|
47
|
-
offset += count
|
|
48
|
-
}
|
|
49
|
-
return buffer
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The archive file format: a magic header, then for each file its name, size and SHA-256 followed by its bytes.
|
|
54
|
-
* Extraction checks every checksum and refuses names that would leave the destination. The `magic` is what identifies
|
|
55
|
-
* archives of one product, so an application keeps the one its existing archives start with.
|
|
56
|
-
*/
|
|
57
|
-
export function createArchiveFormat(options: { magic: string }) {
|
|
58
|
-
const MAGIC = Buffer.from(options.magic)
|
|
59
|
-
|
|
60
|
-
function createArchive(output: string, entries: ArchiveEntry[]): void {
|
|
61
|
-
const fd = fs.openSync(output, 'wx', 0o600)
|
|
62
|
-
try {
|
|
63
|
-
fs.writeSync(fd, MAGIC)
|
|
64
|
-
for (const entry of entries) {
|
|
65
|
-
const name = Buffer.from(safeName(entry.name), 'utf8')
|
|
66
|
-
const stat = fs.statSync(entry.source)
|
|
67
|
-
if (!stat.isFile()) throw new Error(`Archive source is not a file: ${entry.source}`)
|
|
68
|
-
const header = Buffer.alloc(4 + 8 + HASH_SIZE)
|
|
69
|
-
header.writeUInt32BE(name.length, 0)
|
|
70
|
-
header.writeBigUInt64BE(BigInt(stat.size), 4)
|
|
71
|
-
sha256(entry.source).copy(header, 12)
|
|
72
|
-
fs.writeSync(fd, header)
|
|
73
|
-
fs.writeSync(fd, name)
|
|
74
|
-
const input = fs.openSync(entry.source, 'r')
|
|
75
|
-
try { copyBytes(input, fd, stat.size) } finally { fs.closeSync(input) }
|
|
76
|
-
}
|
|
77
|
-
fs.writeSync(fd, Buffer.alloc(4))
|
|
78
|
-
fs.fsyncSync(fd)
|
|
79
|
-
} finally { fs.closeSync(fd) }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function extractArchive(input: string, destination: string): string[] {
|
|
83
|
-
fs.mkdirSync(destination, { recursive: true, mode: 0o700 })
|
|
84
|
-
const root = path.resolve(destination)
|
|
85
|
-
const extracted: string[] = []
|
|
86
|
-
const fd = fs.openSync(input, 'r')
|
|
87
|
-
try {
|
|
88
|
-
if (!readExact(fd, MAGIC.length).equals(MAGIC)) throw new Error('Unsupported backup format')
|
|
89
|
-
for (let index = 0; index < 10000; index++) {
|
|
90
|
-
const nameLength = readExact(fd, 4).readUInt32BE(0)
|
|
91
|
-
if (nameLength === 0) return extracted
|
|
92
|
-
if (nameLength > 4096) throw new Error('Invalid archive entry name')
|
|
93
|
-
const metadata = readExact(fd, 8 + HASH_SIZE)
|
|
94
|
-
const sizeBig = metadata.readBigUInt64BE(0)
|
|
95
|
-
if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Archive entry is too large')
|
|
96
|
-
const expectedHash = metadata.subarray(8)
|
|
97
|
-
const name = safeName(readExact(fd, nameLength).toString('utf8'))
|
|
98
|
-
const target = path.resolve(root, ...name.split('/'))
|
|
99
|
-
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error('Archive path escapes destination')
|
|
100
|
-
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
101
|
-
const output = fs.openSync(target, 'wx', 0o600)
|
|
102
|
-
const hash = crypto.createHash('sha256')
|
|
103
|
-
try { copyBytes(fd, output, Number(sizeBig), hash) } finally { fs.closeSync(output) }
|
|
104
|
-
if (!crypto.timingSafeEqual(hash.digest(), expectedHash)) throw new Error(`Checksum mismatch: ${name}`)
|
|
105
|
-
extracted.push(name)
|
|
106
|
-
}
|
|
107
|
-
throw new Error('Archive contains too many entries')
|
|
108
|
-
} finally { fs.closeSync(fd) }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { createArchive, extractArchive }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export type ArchiveFormat = ReturnType<typeof createArchiveFormat>
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const HASH_SIZE = 32
|
|
6
|
+
|
|
7
|
+
export interface ArchiveEntry { name: string; source: string }
|
|
8
|
+
|
|
9
|
+
function safeName(name: string): string {
|
|
10
|
+
const normalized = name.replaceAll('\\', '/')
|
|
11
|
+
if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
|
|
12
|
+
throw new Error(`Unsafe archive path: ${name}`)
|
|
13
|
+
}
|
|
14
|
+
return normalized
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sha256(file: string): Buffer {
|
|
18
|
+
const hash = crypto.createHash('sha256')
|
|
19
|
+
const fd = fs.openSync(file, 'r')
|
|
20
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
21
|
+
try {
|
|
22
|
+
let count = 0
|
|
23
|
+
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count))
|
|
24
|
+
} finally { fs.closeSync(fd) }
|
|
25
|
+
return hash.digest()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyBytes(input: number, output: number, size: number, hash?: crypto.Hash): void {
|
|
29
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
30
|
+
let remaining = size
|
|
31
|
+
while (remaining > 0) {
|
|
32
|
+
const count = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null)
|
|
33
|
+
if (count === 0) throw new Error('Unexpected end of backup archive')
|
|
34
|
+
const chunk = buffer.subarray(0, count)
|
|
35
|
+
if (hash) hash.update(chunk)
|
|
36
|
+
fs.writeSync(output, chunk)
|
|
37
|
+
remaining -= count
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readExact(fd: number, size: number): Buffer {
|
|
42
|
+
const buffer = Buffer.alloc(size)
|
|
43
|
+
let offset = 0
|
|
44
|
+
while (offset < size) {
|
|
45
|
+
const count = fs.readSync(fd, buffer, offset, size - offset, null)
|
|
46
|
+
if (!count) throw new Error('Truncated backup archive')
|
|
47
|
+
offset += count
|
|
48
|
+
}
|
|
49
|
+
return buffer
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The archive file format: a magic header, then for each file its name, size and SHA-256 followed by its bytes.
|
|
54
|
+
* Extraction checks every checksum and refuses names that would leave the destination. The `magic` is what identifies
|
|
55
|
+
* archives of one product, so an application keeps the one its existing archives start with.
|
|
56
|
+
*/
|
|
57
|
+
export function createArchiveFormat(options: { magic: string }) {
|
|
58
|
+
const MAGIC = Buffer.from(options.magic)
|
|
59
|
+
|
|
60
|
+
function createArchive(output: string, entries: ArchiveEntry[]): void {
|
|
61
|
+
const fd = fs.openSync(output, 'wx', 0o600)
|
|
62
|
+
try {
|
|
63
|
+
fs.writeSync(fd, MAGIC)
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const name = Buffer.from(safeName(entry.name), 'utf8')
|
|
66
|
+
const stat = fs.statSync(entry.source)
|
|
67
|
+
if (!stat.isFile()) throw new Error(`Archive source is not a file: ${entry.source}`)
|
|
68
|
+
const header = Buffer.alloc(4 + 8 + HASH_SIZE)
|
|
69
|
+
header.writeUInt32BE(name.length, 0)
|
|
70
|
+
header.writeBigUInt64BE(BigInt(stat.size), 4)
|
|
71
|
+
sha256(entry.source).copy(header, 12)
|
|
72
|
+
fs.writeSync(fd, header)
|
|
73
|
+
fs.writeSync(fd, name)
|
|
74
|
+
const input = fs.openSync(entry.source, 'r')
|
|
75
|
+
try { copyBytes(input, fd, stat.size) } finally { fs.closeSync(input) }
|
|
76
|
+
}
|
|
77
|
+
fs.writeSync(fd, Buffer.alloc(4))
|
|
78
|
+
fs.fsyncSync(fd)
|
|
79
|
+
} finally { fs.closeSync(fd) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractArchive(input: string, destination: string): string[] {
|
|
83
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 })
|
|
84
|
+
const root = path.resolve(destination)
|
|
85
|
+
const extracted: string[] = []
|
|
86
|
+
const fd = fs.openSync(input, 'r')
|
|
87
|
+
try {
|
|
88
|
+
if (!readExact(fd, MAGIC.length).equals(MAGIC)) throw new Error('Unsupported backup format')
|
|
89
|
+
for (let index = 0; index < 10000; index++) {
|
|
90
|
+
const nameLength = readExact(fd, 4).readUInt32BE(0)
|
|
91
|
+
if (nameLength === 0) return extracted
|
|
92
|
+
if (nameLength > 4096) throw new Error('Invalid archive entry name')
|
|
93
|
+
const metadata = readExact(fd, 8 + HASH_SIZE)
|
|
94
|
+
const sizeBig = metadata.readBigUInt64BE(0)
|
|
95
|
+
if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Archive entry is too large')
|
|
96
|
+
const expectedHash = metadata.subarray(8)
|
|
97
|
+
const name = safeName(readExact(fd, nameLength).toString('utf8'))
|
|
98
|
+
const target = path.resolve(root, ...name.split('/'))
|
|
99
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error('Archive path escapes destination')
|
|
100
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
101
|
+
const output = fs.openSync(target, 'wx', 0o600)
|
|
102
|
+
const hash = crypto.createHash('sha256')
|
|
103
|
+
try { copyBytes(fd, output, Number(sizeBig), hash) } finally { fs.closeSync(output) }
|
|
104
|
+
if (!crypto.timingSafeEqual(hash.digest(), expectedHash)) throw new Error(`Checksum mismatch: ${name}`)
|
|
105
|
+
extracted.push(name)
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Archive contains too many entries')
|
|
108
|
+
} finally { fs.closeSync(fd) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { createArchive, extractArchive }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ArchiveFormat = ReturnType<typeof createArchiveFormat>
|
package/src/cli.ts
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
|
-
import type { BackupService } from './service.js'
|
|
3
|
-
|
|
4
|
-
function argument(argv: string[], name: string): string | undefined {
|
|
5
|
-
const index = argv.indexOf(name)
|
|
6
|
-
return index >= 0 ? argv[index + 1] : undefined
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function fail(error: unknown): void {
|
|
10
|
-
console.error(error instanceof Error ? error.message : error)
|
|
11
|
-
process.exitCode = 1
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface BackupCliOptions {
|
|
15
|
-
service: Pick<BackupService, 'createBackup' | 'validateBackup'>
|
|
16
|
-
/** The application's database handle: opened for the backup and closed after it. */
|
|
17
|
-
database: { open: () => void; close: () => void }
|
|
18
|
-
/** A backup file name as the usage line shows it (`<name>-backup-timestamp.backup`). */
|
|
19
|
-
exampleFile: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The command line of `backup`: makes a backup (`--output <dir>` picks where) or, with `--verify <file>` /
|
|
24
|
-
* `--verify --input <file>`, checks one. Prints JSON; failures set a non-zero exit code.
|
|
25
|
-
*/
|
|
26
|
-
export async function runBackupCli(argv: string[], options: BackupCliOptions): Promise<void> {
|
|
27
|
-
try {
|
|
28
|
-
const verifyMode = argv.includes('--verify')
|
|
29
|
-
const verify = argument(argv, '--input') ?? argument(argv, '--verify')
|
|
30
|
-
if (verifyMode) {
|
|
31
|
-
if (!verify || verify.startsWith('--')) throw new Error(`Usage: backup:verify -- --input <${options.exampleFile}>`)
|
|
32
|
-
const manifest = options.service.validateBackup(path.resolve(verify))
|
|
33
|
-
console.log(JSON.stringify({ valid: true, manifest }, null, 2))
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
options.database.open()
|
|
37
|
-
try {
|
|
38
|
-
const output = argument(argv, '--output')
|
|
39
|
-
console.log(JSON.stringify(await options.service.createBackup(output ? path.resolve(output) : undefined), null, 2))
|
|
40
|
-
} finally { options.database.close() }
|
|
41
|
-
} catch (error) { fail(error) }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface RestoreCliOptions {
|
|
45
|
-
restore: (input: string, extra: string) => Promise<unknown>
|
|
46
|
-
exampleFile: string
|
|
47
|
-
/** The one extra value the restore needs (for example the key the backup was made with). */
|
|
48
|
-
extra: { flag: string; placeholder: string }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The command line of `restore`: `--input <file>` and the extra flag are both required. */
|
|
52
|
-
export async function runRestoreCli(argv: string[], options: RestoreCliOptions): Promise<void> {
|
|
53
|
-
const input = argument(argv, '--input')
|
|
54
|
-
const extra = argument(argv, options.extra.flag)
|
|
55
|
-
if (!input || !extra) {
|
|
56
|
-
console.error(`Usage: restore --input <${options.exampleFile}> ${options.extra.flag} ${options.extra.placeholder}`)
|
|
57
|
-
process.exitCode = 1
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
try { console.log(JSON.stringify(await options.restore(path.resolve(input), extra), null, 2)) }
|
|
61
|
-
catch (error) { fail(error) }
|
|
62
|
-
}
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { BackupService } from './service.js'
|
|
3
|
+
|
|
4
|
+
function argument(argv: string[], name: string): string | undefined {
|
|
5
|
+
const index = argv.indexOf(name)
|
|
6
|
+
return index >= 0 ? argv[index + 1] : undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fail(error: unknown): void {
|
|
10
|
+
console.error(error instanceof Error ? error.message : error)
|
|
11
|
+
process.exitCode = 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BackupCliOptions {
|
|
15
|
+
service: Pick<BackupService, 'createBackup' | 'validateBackup'>
|
|
16
|
+
/** The application's database handle: opened for the backup and closed after it. */
|
|
17
|
+
database: { open: () => void; close: () => void }
|
|
18
|
+
/** A backup file name as the usage line shows it (`<name>-backup-timestamp.backup`). */
|
|
19
|
+
exampleFile: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The command line of `backup`: makes a backup (`--output <dir>` picks where) or, with `--verify <file>` /
|
|
24
|
+
* `--verify --input <file>`, checks one. Prints JSON; failures set a non-zero exit code.
|
|
25
|
+
*/
|
|
26
|
+
export async function runBackupCli(argv: string[], options: BackupCliOptions): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const verifyMode = argv.includes('--verify')
|
|
29
|
+
const verify = argument(argv, '--input') ?? argument(argv, '--verify')
|
|
30
|
+
if (verifyMode) {
|
|
31
|
+
if (!verify || verify.startsWith('--')) throw new Error(`Usage: backup:verify -- --input <${options.exampleFile}>`)
|
|
32
|
+
const manifest = options.service.validateBackup(path.resolve(verify))
|
|
33
|
+
console.log(JSON.stringify({ valid: true, manifest }, null, 2))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
options.database.open()
|
|
37
|
+
try {
|
|
38
|
+
const output = argument(argv, '--output')
|
|
39
|
+
console.log(JSON.stringify(await options.service.createBackup(output ? path.resolve(output) : undefined), null, 2))
|
|
40
|
+
} finally { options.database.close() }
|
|
41
|
+
} catch (error) { fail(error) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RestoreCliOptions {
|
|
45
|
+
restore: (input: string, extra: string) => Promise<unknown>
|
|
46
|
+
exampleFile: string
|
|
47
|
+
/** The one extra value the restore needs (for example the key the backup was made with). */
|
|
48
|
+
extra: { flag: string; placeholder: string }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The command line of `restore`: `--input <file>` and the extra flag are both required. */
|
|
52
|
+
export async function runRestoreCli(argv: string[], options: RestoreCliOptions): Promise<void> {
|
|
53
|
+
const input = argument(argv, '--input')
|
|
54
|
+
const extra = argument(argv, options.extra.flag)
|
|
55
|
+
if (!input || !extra) {
|
|
56
|
+
console.error(`Usage: restore --input <${options.exampleFile}> ${options.extra.flag} ${options.extra.placeholder}`)
|
|
57
|
+
process.exitCode = 1
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
try { console.log(JSON.stringify(await options.restore(path.resolve(input), extra), null, 2)) }
|
|
61
|
+
catch (error) { fail(error) }
|
|
62
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { createArchiveFormat, type ArchiveEntry, type ArchiveFormat } from './archive.js'
|
|
2
|
-
export {
|
|
3
|
-
collectFiles, createBackupService, DEFAULT_BACKUP_CONFIG, DEFAULT_BACKUP_STATUS, INTERRUPTED_BACKUP_ERROR,
|
|
4
|
-
type BackupConfig, type BackupInfo, type BackupManifest, type BackupService, type BackupServiceOptions, type BackupStatus, type ProcessOwner,
|
|
5
|
-
} from './service.js'
|
|
6
|
-
export { createRestore, replaceFile, type RestoreExtras, type RestoreOptions, type RestoreResult } from './restore.js'
|
|
7
|
-
export { createBackupRunner, spawnBackupWorker, type BackupJob, type BackupWorkerOptions } from './runner.js'
|
|
8
|
-
export { createBackupScheduler, scheduleExpression, type BackupSchedulerOptions } from './scheduler.js'
|
|
9
|
-
export { createBackupRoutes, type BackupRoutesOptions } from './routes.js'
|
|
10
|
-
export { runBackupCli, runRestoreCli, type BackupCliOptions, type RestoreCliOptions } from './cli.js'
|
|
1
|
+
export { createArchiveFormat, type ArchiveEntry, type ArchiveFormat } from './archive.js'
|
|
2
|
+
export {
|
|
3
|
+
collectFiles, createBackupService, DEFAULT_BACKUP_CONFIG, DEFAULT_BACKUP_STATUS, INTERRUPTED_BACKUP_ERROR,
|
|
4
|
+
type BackupConfig, type BackupInfo, type BackupManifest, type BackupService, type BackupServiceOptions, type BackupStatus, type ProcessOwner,
|
|
5
|
+
} from './service.js'
|
|
6
|
+
export { createRestore, replaceFile, type RestoreExtras, type RestoreOptions, type RestoreResult } from './restore.js'
|
|
7
|
+
export { createBackupRunner, spawnBackupWorker, type BackupJob, type BackupWorkerOptions } from './runner.js'
|
|
8
|
+
export { createBackupScheduler, scheduleExpression, type BackupSchedulerOptions } from './scheduler.js'
|
|
9
|
+
export { createBackupRoutes, type BackupRoutesOptions } from './routes.js'
|
|
10
|
+
export { runBackupCli, runRestoreCli, type BackupCliOptions, type RestoreCliOptions } from './cli.js'
|