Skip to content

本轮为理论笔记与静态审读,示例、安装命令和验收器均未执行。下文命令用于解释步骤,预期结果不代表本轮实测;“历史验收”仅指此前批次记录。阅读和书面练习不需要启动服务。

完整 MinIO SDK 对象备份与恢复程序 ​

备份合同与依赖 ​

本项目是完整 CLI:backup 列举桶内当前可见对象、下载到本地并生成 manifest;restore 校验本地数据、上传到目标桶,再下载逐字节检查。前置:Node >=22、已启动课程 MinIO、会设置环境变量。依赖固定为维护者 minio 8.0.6,package-lock.json 锁定传递依赖,使用 npm ci;不依赖本站后端或外部 Postman workspace。

json
{
  "name": "showdocs-minio-backup",
  "private": true,
  "type": "module",
  "scripts": { "test": "node --test app.test.mjs", "verify": "node verify.mjs" },
  "dependencies": { "minio": "8.0.6" },
  "engines": { "node": ">=22" }
}

这是当前对象备份,不备份历史 versionId、删除标记、桶策略、生命周期、对象锁或所有元数据。恢复产生新对象,不保留原 versionId。因此“版本控制已启用”与“历史版本已备份”不能画等号。写入期间的列举/下载不是跨对象一致性快照;需要一致快照时,应冻结写入或建立版本化清单与业务检查点。

传递依赖的完整锁定结果在仓库 examples/minio/package-lock.json;它与上面的 package.json 一起组成项目,不用手工补依赖。启动参数见下方脚本(仅展示):

ps1
$ErrorActionPreference = 'Stop'
docker build --pull=false -t showdocs-m4b-minio:2025-10-15 examples/minio
if ($LASTEXITCODE -ne 0) { throw 'source build failed' }
docker run -d --name showdocs-m4b-minio-main -p 127.0.0.1:23000:9000 -v showdocs-m4b-minio-data:/data -e MINIO_ROOT_USER=m4b-local -e MINIO_ROOT_PASSWORD=m4b-local-secret-only showdocs-m4b-minio:2025-10-15 server /data --console-address :9001
if ($LASTEXITCODE -ne 0) { throw 'MinIO start failed' }

完整源码与路径设计 ​

源码是运行和页面共用的同一文件。getObject 返回流,示例收集到 Buffer 方便展示逐字节校验;对象很大时必须改为流式文件和增量哈希,并处理磁盘容量、分页和重试。不要据此推断可以把任意大对象放进内存。

本地路径安全不能依靠 S3 对象名的表面形式。远端键不会直接用作本地路径;objectFile 以 SHA-256(key) 生成 64 位十六进制文件名,manifest 保存原 key。这样 a.txt 与 A.txt 在 Windows 也不会因大小写映射冲突,CON 等保留名不会成为磁盘文件名。示例额外拒绝绝对路径、盘符、反斜杠、控制字符与点段,支持范围是简单相对对象键,拒绝不等于这些键在 S3 协议非法。

备份目录须由当前用户控制且不含不可信符号链接;程序没有实现对抗本机恶意用户替换符号链接的隔离。manifest 的哈希是误损坏检测,不是防篡改签名:若攻击者同时改数据和 manifest,普通 SHA-256 比对不能证明来源真实性。

mjs
import { Client } from 'minio';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { pathToFileURL } from 'node:url';
export function verifyBytes(expected, actual) {
  if (!expected.equals(actual)) throw new Error('byte mismatch');
  return createHash('sha256').update(actual).digest('hex');
}
export function safeKey(key) {
  if (!key || key.startsWith('/') || /[\\:\x00-\x1f]/.test(key) || key.split('/').some(part => !part || part === '..' || part === '.')) throw new Error('unsafe object key');
  return key;
}
export function client(port = Number(process.env.S3_PORT || 23000)) {
  const c = new Client({ endPoint: '127.0.0.1', port, useSSL: false, accessKey: process.env.S3_ACCESS_KEY || 'm4b-local', secretKey: process.env.S3_SECRET_KEY || 'm4b-local-secret-only' });
  c.setRequestOptions({ timeout: 3000 });
  return c;
}
export async function bytes(stream) { const chunks = []; for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks); }
export function objectFile(folder, key) { return resolve(folder, 'objects', createHash('sha256').update(safeKey(key)).digest('hex')); }
export async function backup(c, bucket, folder) {
  const manifest = [];
  // This teaching backup saves current visible versions only, not policies or history.
  for await (const object of c.listObjectsV2(bucket, '', true)) {
    const key = safeKey(object.name);
    const data = await bytes(await c.getObject(bucket, key));
    const hash = createHash('sha256').update(data).digest('hex');
    const file = objectFile(folder, key);
    await mkdir(dirname(file), { recursive: true });
    await writeFile(file, data);
    manifest.push({ key, bytes: data.length, sha256: hash });
  }
  await mkdir(folder, { recursive: true });
  await writeFile(resolve(folder, 'manifest.json'), JSON.stringify(manifest, null, 2));
  return manifest;
}
export async function restore(c, bucket, folder) {
  const manifest = JSON.parse(await readFile(resolve(folder, 'manifest.json'), 'utf8'));
  if (!await c.bucketExists(bucket)) await c.makeBucket(bucket);
  for (const entry of manifest) {
    const data = await readFile(objectFile(folder, entry.key));
    if (createHash('sha256').update(data).digest('hex') !== entry.sha256) throw new Error('backup checksum mismatch');
    await c.putObject(bucket, entry.key, data);
    verifyBytes(data, await bytes(await c.getObject(bucket, entry.key)));
  }
  return manifest.length;
}
async function main([command, bucket, folder]) {
  if (!['backup', 'restore'].includes(command) || !bucket || !folder) throw new Error('usage: node app.mjs backup|restore BUCKET FOLDER');
  console.log(JSON.stringify(await (command === 'backup' ? backup : restore)(client(), bucket, folder)));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(error => { console.error(error.message); process.exitCode = 1; });

运行与输出 ​

不必先运行具有停源、创建恢复容器等副作用的验收器。理论最小路径是在完成入门环境说明后,保存下面完整文件为 examples/minio/seed.mjs;它使用项目已经声明的 SDK,创建专用新桶并写入一个当前对象。固定桶名只用于首次演示,已存在时先核对归属,不覆盖其他人的桶。

创建桶和上传不是一个事务:若上传失败,专用桶可能已经存在,重跑会明确拒绝。先核对该桶状态;可以保留失败现场并在脚本中换一个新专用桶名,同时修改后续 backup 参数,不靠删除未知桶来重跑。

mjs
// Optional isolated teaching setup. Not run by the site's static checks.
import { client } from './app.mjs';
const c = client();
const bucket = 'course-backup-demo';
if (await c.bucketExists(bucket)) throw new Error('demo bucket already exists; choose a new dedicated name');
await c.makeBucket(bucket);
await c.putObject(bucket, 'notes/hello.txt', Buffer.from('hello\n', 'utf8'));
console.log(bucket);

以下为供学习的顺序命令,不是本站验证过程。预期 seed 输出桶名;backup 返回一个条目(key=notes/hello.txt、bytes=6),restore 返回 1。备份目录使用专用新目录,避免失败时混入前次 manifest:

powershell
npm ci --prefix examples/minio --ignore-scripts --no-audit --no-fund
node examples/minio/seed.mjs
node examples/minio/app.mjs backup course-backup-demo examples/minio/my-backup
node examples/minio/app.mjs restore recovered-current examples/minio/my-backup

backup 输出含 key、bytes、sha256 的数组;restore 输出成功对象数量。当前示例目标通过环境变量 S3_PORT/S3_ACCESS_KEY/S3_SECRET_KEY 指定,主机固定 127.0.0.1。PowerShell 用 $env:S3_PORT='23000',Bash 用 S3_PORT=23000 node ...。缺参数、权限拒绝、断连、哈希不匹配都退出 1,只有完成全部断言才为 0。

恢复会覆盖目标同名可见对象;生产恢复应先用独立桶或独立实例,核验后再切流。程序按对象顺序执行,失败前可能已有部分对象写入,不宣称跨对象事务。重试前用 manifest 和目标内容核对,而不是假设失败意味着零副作用。

真实服务验收器 ​

历史验收器入口是 node examples/minio/verify.mjs,它创建唯一桶、上传两版、验证 hash/逐字节/列举/缺失键/签名拒绝/删除语义,破坏本地副本确认恢复拒绝,然后停止源实例,确认 CLI 非零。它用新的 showdocs-m4b-minio-restore-data 启动独立目标,复用源已释放的 127.0.0.1:23000;恢复后读回成功,再停目标、重启源。

恢复目标在源停止期间占用同一端口,连接对象重新创建以免复用旧连接;两个数据卷完全独立。重复验收前仅清理上次恢复容器/卷,命令见运维清单;不要让源与恢复目标同时抢同一端口。测试生成 backup-output 和结果是本机实验产物。

mjs
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import { backup, restore, client, bytes, verifyBytes, objectFile } from './app.mjs';
const records = [];
const record = (name, result) => records.push({ name, exit: 0, result });
function docker(args) {
  const r = spawnSync('docker', args, { encoding: 'utf8', timeout: 30000 });
  records.push({ command: ['docker', ...args].join(' '), exit: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim() });
  assert.equal(r.status, 0, r.stderr); return r.stdout.trim();
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const c = client();
const bucket = `course-${Date.now()}`;
try {
  await c.makeBucket(bucket); await c.setBucketVersioning(bucket, { Status: 'Enabled' });
  const first = Buffer.from('对象备份\nversion one\0\xff', 'utf8');
  const second = Buffer.from('对象备份\nversion two\0\xff', 'utf8');
  const v1 = await c.putObject(bucket, 'notes/data.bin', first);
  const v2 = await c.putObject(bucket, 'notes/data.bin', second);
  assert.ok(v1.versionId && v2.versionId && v1.versionId !== v2.versionId);
  const hash = verifyBytes(second, await bytes(await c.getObject(bucket, 'notes/data.bin')));
  const listed = []; for await (const item of c.listObjectsV2(bucket, '', true)) listed.push(item.name);
  assert.deepEqual(listed, ['notes/data.bin']); record('upload-download-list-sha256', { bucket, hash, bytes: second.length });
  await assert.rejects(c.getObject(bucket, 'missing'), e => e.code === 'NoSuchKey'); record('missing-key', 'NoSuchKey');
  const url = await c.presignedGetObject(bucket, 'notes/data.bin', 60);
  const signed = await fetch(url); assert.equal(signed.status, 200); verifyBytes(second, Buffer.from(await signed.arrayBuffer()));
  const tampered = new URL(url); tampered.searchParams.set('X-Amz-Signature', '0'.repeat(64));
  const denied = await fetch(tampered); assert.equal(denied.status, 403); assert.match(await denied.text(), /SignatureDoesNotMatch/);
  const anonymous = await fetch(`http://127.0.0.1:23000/${bucket}/notes/data.bin`); assert.equal(anonymous.status, 403);
  record('signed-url-contract', { valid: 200, tampered: 403, anonymous: 403, lifetimeSeconds: 60, expiredURL: 'not timing-tested; no expiry claim' });
  await c.removeObject(bucket, 'notes/data.bin');
  await assert.rejects(c.getObject(bucket, 'notes/data.bin'), e => e.code === 'NoSuchKey');
  verifyBytes(first, await bytes(await c.getObject(bucket, 'notes/data.bin', { versionId: v1.versionId })));
  const versions = []; for await (const item of c.listObjects(bucket, '', true, { IncludeVersion: true })) versions.push({ versionId: item.versionId, isDeleteMarker: item.isDeleteMarker, name: item.name });
  assert.ok(versions.some(v => v.isDeleteMarker));
  await c.removeObject(bucket, 'notes/data.bin', { versionId: v1.versionId });
  await assert.rejects(c.getObject(bucket, 'notes/data.bin', { versionId: v1.versionId }));
  record('version-and-delete-marker', { v1: v1.versionId, v2: v2.versionId, versions, permanentVersionDelete: true });
  // Make a visible current version again before the current-object backup.
  await c.putObject(bucket, 'notes/data.bin', second);
  const folder = 'examples/minio/backup-output';
  const manifest = await backup(c, bucket, folder); assert.equal(manifest.length, 1);
  // Verify local corruption is rejected before upload.
  await writeFile(objectFile(folder, 'notes/data.bin'), first);
  await assert.rejects(restore(c, bucket, folder), /checksum mismatch/);
  await writeFile(objectFile(folder, 'notes/data.bin'), second);
  record('backup-manifest-and-corruption', manifest);
  docker(['stop', 'showdocs-m4b-minio-main']);
  const failure = spawnSync(process.execPath, ['examples/minio/app.mjs', 'backup', bucket, 'examples/minio/stopped-output'], { encoding: 'utf8', timeout: 20000 });
  assert.notEqual(failure.status, 0); records.push({ command: 'backup stopped source', exit: failure.status, stderr: failure.stderr.trim() });
  docker(['run', '-d', '--name', 'showdocs-m4b-minio-restore', '-p', '127.0.0.1:23000:9000', '-v', 'showdocs-m4b-minio-restore-data:/data', '-e', 'MINIO_ROOT_USER=m4b-local', '-e', 'MINIO_ROOT_PASSWORD=m4b-local-secret-only', 'showdocs-m4b-minio:2025-10-15', 'server', '/data', '--console-address', ':9001']);
  await sleep(2500);
  const target = client();
  assert.equal(await target.bucketExists(bucket), false);
  assert.equal(await restore(target, bucket, folder), 1);
  verifyBytes(second, await bytes(await target.getObject(bucket, 'notes/data.bin')));
  record('independent-volume-restore', { bucket, hash, restoredCurrentObjects: 1, versionHistoryRestored: false });
  docker(['stop', 'showdocs-m4b-minio-restore']); docker(['start', 'showdocs-m4b-minio-main']); await sleep(2000);
  verifyBytes(second, await bytes(await client().getObject(bucket, 'notes/data.bin'))); record('source-restart-read', hash);
} finally {
  await writeFile('migration/knowledge-rebuild/m4-next/coordination-storage/minio-results.json', JSON.stringify(records, null, 2));
}

聚焦单测覆盖字节不匹配、路径输入、Windows 盘符与大小写不同键的本地映射。下列单测源码与服务验收器共同定义预期,不代表本轮执行通过:

mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifyBytes, safeKey, objectFile } from './app.mjs';
test('backup verifies bytes and refuses path traversal', () => {
  assert.equal(verifyBytes(Buffer.from('hello'), Buffer.from('hello')).length, 64);
  assert.throws(() => verifyBytes(Buffer.from('hello'), Buffer.from('hellO')), /mismatch/);
  assert.throws(() => safeKey('../outside'), /unsafe/);
  assert.throws(() => safeKey('C:/outside'), /unsafe/);
  assert.equal(safeKey('notes/a.txt'), 'notes/a.txt');
  assert.notEqual(objectFile('backup', 'a.txt'), objectFile('backup', 'A.txt'));
  assert.match(objectFile('backup', 'notes/a.txt'), /objects[\\/][a-f0-9]{64}$/);
});

练习与参考解法 ​

题目:备份途中另一个客户端覆盖了已经列举的对象,manifest 能否代表列举开始的同一时刻?提示:在时间线上标记“列举 key”与“下载内容”,检查两个操作之间是否允许覆盖。

参考答案与边界

不能,本程序下载的是请求时可见版本。可扩展版本化清单,先固定每个 key/versionId,再按 versionId 下载,并将删除标记纳入合同;仍需业务定义跨键一致性检查点,不能只加一个时间戳就称快照。

继续阅读