openvibers/openvibe.media Commits

Media S3: CopyObject and UploadPartCopy — server-side copies through the one commit path (#113)

OP OpenVibers committed 29 days ago · e54f2df104 · 4 files, +304 −13

Changes

ddiff --git a/docs/s3.md b/docs/s3.md
iindex 4977e1e..bebe534 100644
---- a/docs/s3.md
++++ b/docs/s3.md
@@@ -21,13 +21,19 @@ a named object zone of one project environment. Zones are made and deleted only
**Supported:**
- `PutObject`, in one request, including aws-chunked bodies. `Content-MD5`, `x-amz-content-sha256` and the `x-amz-checksum-*` headers and trailers are checked.
- Multipart uploads (`server/s3/multipart.js`): `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`, `AbortMultipartUpload`, `ListParts` and `ListMultipartUploads`. They follow S3's rules: every part but the last is at least 5 MiB, Complete names parts in ascending order with their ETags, and the object's ETag is the MD5 of the parts' MD5s with `-<count>`. Parts count toward the quota as they arrive. An upload expires after `MEDIA_MULTIPART_TTL_HOURS`.
++- `CopyObject` and `UploadPartCopy`, server-side, within the key's environment.
++ - The source (`x-amz-copy-source`, `bucket/key` or `/bucket/key`, URL-encoded or not) is read with the same key. The key must be allowed to read that zone, and the copy is written through the one commit path: quota, holds, and the previous object's deletion.
++ - `COPY` (the default) keeps the source's type and metadata; `REPLACE` takes the request's.
++ - A copy onto itself must change something (`REPLACE`).
++ - The `x-amz-copy-source-if-*` conditions hold. `UploadPartCopy` takes `x-amz-copy-source-range`.
++ - A copy counts as one write of the destination zone.
- `GetObject` (with ranges, conditional headers and presigned response overrides) and `HeadObject`.
- `DeleteObject` and `DeleteObjects`.
- `ListObjectsV2` and `ListObjects` (prefix, delimiter and pages).
- `ListBuckets`, `HeadBucket` and `GetBucketLocation`.
- Presigned GET and PUT, valid for 7 days at most.
--**Not implemented:** `CopyObject`, `UploadPartCopy`, ACLs, policies, versioning, tagging and bucket deletion.
++**Not implemented:** ACLs, policies, versioning (a `versionId` copy source too), tagging and bucket deletion.
`CreateBucket` answers 200 for a zone that already exists, so tools that probe this way keep working.
## Rules
@@@ -69,6 +75,7 @@ names no zone of its key (`NoSuchBucket`, a bad signature) counts nowhere.
- `test/s3-zones.test.js` runs end to end with the AWS SDK for JavaScript v3: every operation, presigned URLs, isolation, quotas, holds, two doors and zone deletion.
- `test/s3-multipart.test.js` uses the SDK's `Upload` helper (parallel parts) and the raw commands: S3's rules, ETags, abort, quota and expiry.
++- `test/s3-copy.test.js` copies with the SDK: COPY and REPLACE, onto itself, across zones, the conditions, scopes, quotas, UploadPartCopy by ranges, and the usage count.
- `test/s3-usage.test.js` counts every series through the SDK, then checks the rollups against the contract, their revisions and the pruning.
- `test/s3-sigv4.test.js` checks the worked examples of the S3 API reference, including the chunk signature chain.
ddiff --git a/server/s3/routes.js b/server/s3/routes.js
iindex ba3dd68..793483c 100644
---- a/server/s3/routes.js
++++ b/server/s3/routes.js
@@@ -228,14 +228,24 @@ async function sendObject(req, res, obj, { head = false } = {}) {
if (require('../drill').refuseBytes(res)) return;
const from = range || (size ? { start: 0, end: size - 1 } : null);
if (!from) return res.end();
-- // The placement router ranks the copies; a local one streams from disk, a remote one is fetched and passed on
-- // (S3 clients do not follow redirects).
++ const src = await openBytes(req, obj, from);
++ if (src) return await pipeline(src, res).catch(() => res.destroy());
++ if (!res.headersSent) return s3error(req, res, 'InternalError', 'The object bytes are unavailable right now');
++ res.destroy();
++}
++
++/**
++ * A readable stream of the object's bytes from..to (inclusive), or null when no copy answers. The placement router
++ * ranks the copies; a local one streams from disk, a remote one is fetched and passed on (S3 clients do not follow
++ * redirects). GetObject and the copies (CopyObject, UploadPartCopy) read through here.
++ */
++async function openBytes(req, obj, from) {
const router = require('../placement/router');
-- const decision = await router.route({ object: obj, purpose: 'download', session: router.sessionFor(req, obj.id), contentType: headers['Content-Type'], expiresIn: 300 });
++ const decision = await router.route({ object: obj, purpose: 'download', session: router.sessionFor(req, obj.id), contentType: obj.mime_type || 'application/octet-stream', expiresIn: 300 });
for (const loc of decision.candidates || []) {
if (loc.provider === 'local') {
if (!fs.existsSync(loc.key)) continue;
-- return await pipeline(fs.createReadStream(loc.key, { start: from.start, end: from.end }), res).catch(() => res.destroy());
++ return fs.createReadStream(loc.key, { start: from.start, end: from.end });
}
const url = (loc.provider === decision.provider && decision.url)
|| await require('../vod/vod-storage').presignGet(loc.provider, loc.key, 300, {}).catch(() => null);
@@@ -243,10 +253,9 @@ async function sendObject(req, res, obj, { head = false } = {}) {
const up = await fetch(url, { headers: { Range: `bytes=${from.start}-${from.end}` }, signal: AbortSignal.timeout(30_000) }).catch(() => null);
if (!up || !(up.status === 206 || up.status === 200) || !up.body) continue;
const { Readable } = require('stream');
-- return await pipeline(Readable.fromWeb(up.body), res).catch(() => res.destroy());
++ return Readable.fromWeb(up.body);
}
-- if (!res.headersSent) return s3error(req, res, 'InternalError', 'The object bytes are unavailable right now');
-- res.destroy();
++ return null;
}
// ── Writing ──────────────────────────────────────────────────
@@@ -383,7 +392,7 @@ async function commitObject(req, zone, key, { tmp, bytes, sha256, mime, md }) {
async function putObject(req, res, zone, key) {
const { key: akey, parsed } = req.s3;
if (!mayWrite(akey)) { req.resume(); return s3error(req, res, 'AccessDenied', 'This access key is read-only'); }
-- if (req.headers['x-amz-copy-source']) { req.resume(); return s3error(req, res, 'NotImplemented', 'CopyObject is not supported yet: read the object and write it again'); }
++ if (req.headers['x-amz-copy-source']) { req.resume(); return await copyObject(req, res, zone, key); }
const acl = String(req.headers['x-amz-acl'] || 'private');
if (acl !== 'private' && acl !== 'bucket-owner-full-control') { req.resume(); return s3error(req, res, 'AccessDenied', 'Zones are private: objects cannot be made public over S3'); }
if (DERIVED_KEY.test(key)) {
@@@ -419,6 +428,123 @@ async function putObject(req, res, zone, key) {
res.status(200).end();
}
++// ── Copies (CopyObject, UploadPartCopy) ──────────────────────
++
++/**
++ * The source x-amz-copy-source names ("bucket/key" or "/bucket/key", URL-encoded), read with the same key: a zone of
++ * the key's environment it may read, and an object there. → { zone, key, obj } or null (the S3 error answered).
++ * The destination stays the zone the request counts against (req.s3.zone).
++ */
++async function copySource(req, res) {
++ const raw = String(req.headers['x-amz-copy-source'] || '');
++ const [pathPart, query] = raw.split('?');
++ if (query && /(^|&)versionId=/.test(query)) { s3error(req, res, 'NotImplemented', 'Zones keep no versions: copy the current object'); return null; }
++ let src;
++ try { src = decodeURIComponent(pathPart).replace(/^\//, ''); } catch { src = ''; }
++ const slash = src.indexOf('/');
++ if (slash < 1 || slash === src.length - 1) { s3error(req, res, 'InvalidArgument', 'Copy Source must mention the source bucket and key: sourcebucket/sourcekey'); return null; }
++ const key = keyOf(src.slice(slash + 1));
++ if (key == null) { s3error(req, res, 'KeyTooLongError', `Keys are 1 to ${MAX_KEY_BYTES} bytes`); return null; }
++ if (!mayRead(req.s3.key)) { s3error(req, res, 'AccessDenied', 'This access key cannot read the copy source'); return null; }
++ const dest = req.s3.zone;
++ const zone = await zoneFor(req, res, src.slice(0, slash));
++ req.s3.zone = dest;
++ if (!zone) return null;
++ const obj = await findObject(zone, req.appId, key);
++ if (!obj) { s3error(req, res, 'NoSuchKey', 'The specified key does not exist.', `<Key>${esc(key)}</Key>`); return null; }
++ const etag = etagOf(obj);
++ const at = lastModified(obj).getTime();
++ const list = (h) => String(req.headers[h] || '').split(',').map((x) => x.trim()).filter(Boolean);
++ const when = (h) => { const d = req.headers[h] ? new Date(req.headers[h]) : null; return d && Number.isFinite(d.getTime()) ? d.getTime() : null; };
++ const im = list('x-amz-copy-source-if-match'), inm = list('x-amz-copy-source-if-none-match');
++ const ims = when('x-amz-copy-source-if-modified-since'), ius = when('x-amz-copy-source-if-unmodified-since');
++ // S3's order: if-match wins over if-unmodified-since, if-none-match over if-modified-since.
++ const ok = (im.length ? im.includes(etag) || im.includes('*') : (ius == null || at <= ius + 999))
++ && (inm.length ? !inm.includes(etag) : (ims == null || at > ims + 999));
++ if (!ok) { s3error(req, res, 'PreconditionFailed', 'At least one of the pre-conditions you specified did not hold'); return null; }
++ return { zone, key, obj };
++}
++
++/** Copy the source's bytes from..to into a temporary file, hashing them. → { tmp, bytes, sha256, md5 } | { error } */
++async function copyBytes(req, obj, from) {
++ const tmpDir = path.join(config.objects.path, '.tmp');
++ fs.mkdirSync(tmpDir, { recursive: true });
++ const tmp = path.join(tmpDir, `s3c-${crypto.randomBytes(8).toString('hex')}`);
++ const sha = crypto.createHash('sha256'), md5 = crypto.createHash('md5');
++ let bytes = 0;
++ if (!from) { fs.writeFileSync(tmp, ''); return { tmp, bytes: 0, sha256: sha.digest('hex'), md5: md5.digest('hex') }; }
++ if (require('../drill').enabled) return { error: ['ServiceUnavailable', 'This Media is a restore drill: no bytes are served'] };
++ const src = await openBytes(req, obj, from);
++ if (!src) return { error: ['InternalError', 'The copy source bytes are unavailable right now'] };
++ const { Transform } = require('stream');
++ const tap = new Transform({ transform(chunk, _enc, cb) { sha.update(chunk); md5.update(chunk); bytes += chunk.length; cb(null, chunk); } });
++ try {
++ await pipeline(src, tap, fs.createWriteStream(tmp));
++ } catch (err) {
++ try { fs.unlinkSync(tmp); } catch { /* not written */ }
++ return { error: ['InternalError', `The copy did not complete: ${err.message}`] };
++ }
++ if (bytes !== from.end - from.start + 1) { try { fs.unlinkSync(tmp); } catch { /* */ } return { error: ['InternalError', 'The copy source ended early'] }; }
++ return { tmp, bytes, sha256: sha.digest('hex'), md5: md5.digest('hex') };
++}
++
++async function copyObject(req, res, zone, key) {
++ const src = await copySource(req, res);
++ if (!src) return;
++ const directive = String(req.headers['x-amz-metadata-directive'] || 'COPY').toUpperCase();
++ if (directive !== 'COPY' && directive !== 'REPLACE') return s3error(req, res, 'InvalidArgument', 'Unknown metadata directive: COPY or REPLACE');
++ if (src.zone.id === zone.id && src.key === key && directive === 'COPY') {
++ return s3error(req, res, 'InvalidRequest', "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes.");
++ }
++ const size = Number(src.obj.size_bytes) || 0;
++ const cap = (config.objects.multipartMaxMb || 20480) * 1024 * 1024;
++ if (size > cap) return s3error(req, res, 'EntityTooLarge', `A copy in one request is at most ${cap} bytes; use UploadPartCopy`);
++ const pre = await namespaces.checkQuota(req.appRow, zone.namespace, { bytes: size, objects: 0 });
++ if (pre) return s3error(req, res, 'QuotaExceeded', pre.detail);
++ let md, mime;
++ if (directive === 'COPY') {
++ const from = model.parseJson(src.obj.metadata, {});
++ md = { filename: key.split('/').pop().slice(0, 200), s3: true, created_by: req.s3.key.id };
++ for (const f of ['s3_meta', 'content_disposition', 'content_encoding', 'cache_control']) if (from[f] !== undefined) md[f] = from[f];
++ mime = src.obj.mime_type || 'application/octet-stream';
++ } else {
++ const meta = userMeta(req);
++ if (!meta) return s3error(req, res, 'InvalidArgument', `User metadata is limited to ${MAX_META_BYTES} bytes`);
++ md = objectMeta(req, key, meta);
++ mime = mimeOf(req);
++ }
++ const got = await copyBytes(req, src.obj, size ? { start: 0, end: size - 1 } : null);
++ if (got.error) return s3error(req, res, got.error[0], got.error[1]);
++ const outcome = await commitObject(req, zone, key, { tmp: got.tmp, bytes: got.bytes, sha256: got.sha256, mime, md: { ...md, md5: got.md5 } });
++ if (outcome.error) { try { fs.unlinkSync(got.tmp); } catch { /* */ } return s3error(req, res, outcome.error[0], outcome.error[1]); }
++ xml(res, 200, `<CopyObjectResult xmlns="${XMLNS}"><ETag>${esc(etagOf(outcome.obj))}</ETag><LastModified>${isoOf(lastModified(outcome.obj))}</LastModified></CopyObjectResult>`);
++}
++
++async function copyPart(req, res, zone, key, up, n) {
++ const src = await copySource(req, res);
++ if (!src) return;
++ const size = Number(src.obj.size_bytes) || 0;
++ let from = size ? { start: 0, end: size - 1 } : null;
++ const rangeHeader = req.headers['x-amz-copy-source-range'];
++ if (rangeHeader) {
++ const m = /^bytes=(\d+)-(\d+)$/.exec(String(rangeHeader).trim());
++ if (!m || Number(m[1]) > Number(m[2]) || Number(m[2]) >= size) return s3error(req, res, 'InvalidArgument', 'The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy, within the source');
++ from = { start: Number(m[1]), end: Number(m[2]) };
++ }
++ const bytes = from ? from.end - from.start + 1 : 0;
++ const cap = (config.objects.multipartMaxPartMb || 256) * 1024 * 1024;
++ if (bytes > cap) return s3error(req, res, 'EntityTooLarge', `A part is at most ${cap} bytes`);
++ const total = await mp.bytesSoFar(up, n) + bytes;
++ if (total > (config.objects.multipartMaxMb || 20480) * 1024 * 1024) return s3error(req, res, 'EntityTooLarge', 'The object would be larger than this surface accepts');
++ const pre = await namespaces.checkQuota(req.appRow, zone.namespace, { bytes: total, objects: 0 });
++ if (pre) return s3error(req, res, 'QuotaExceeded', pre.detail);
++ const got = await copyBytes(req, src.obj, from);
++ if (got.error) return s3error(req, res, got.error[0], got.error[1]);
++ if (!await mp.find(up.id, zone, key)) { try { fs.unlinkSync(got.tmp); } catch { /* */ } return s3error(req, res, 'NoSuchUpload', 'The upload was aborted or completed while this part was copied'); }
++ const part = await mp.storePart(up, n, got.tmp, { bytes: got.bytes, md5: got.md5 });
++ xml(res, 200, `<CopyPartResult xmlns="${XMLNS}"><ETag>"${part.md5}"</ETag><LastModified>${isoOf(new Date(Number(part.created_at) || Date.now()))}</LastModified></CopyPartResult>`);
++}
++
// ── Multipart uploads ────────────────────────────────────────
/** Read a small XML request body (Complete, DeleteObjects), aws-chunked or not, checking x-amz-content-sha256. */
@@@ -453,11 +579,11 @@ async function createMultipart(req, res, zone, key) {
async function uploadPart(req, res, zone, key) {
const { key: akey, parsed } = req.s3;
if (!mayWrite(akey)) { req.resume(); return s3error(req, res, 'AccessDenied', 'This access key is read-only'); }
-- if (req.headers['x-amz-copy-source']) { req.resume(); return s3error(req, res, 'NotImplemented', 'UploadPartCopy is not supported yet'); }
const n = Number(req.query.partNumber);
if (!Number.isInteger(n) || n < 1 || n > 10000) { req.resume(); return s3error(req, res, 'InvalidArgument', 'Part number must be an integer between 1 and 10000, inclusive'); }
const up = await mp.find(req.query.uploadId, zone, key);
if (!up) { req.resume(); return s3error(req, res, 'NoSuchUpload', 'The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.'); }
++ if (req.headers['x-amz-copy-source']) { req.resume(); return await copyPart(req, res, zone, key, up, n); }
const declared = Number(sigv4.isStreaming(parsed) ? req.headers['x-amz-decoded-content-length'] : req.headers['content-length']);
const cap = (config.objects.multipartMaxPartMb || 256) * 1024 * 1024;
if (!Number.isFinite(declared)) { req.resume(); return s3error(req, res, 'MissingContentLength', 'You must provide the Content-Length HTTP header'); }
ddiff --git a/test/s3-copy.test.js b/test/s3-copy.test.js
nnew file mode 100644
iindex 0000000..5a43f25
---- /dev/null
++++ b/test/s3-copy.test.js
@@@ -0,0 +1,159 @@
++'use strict';
++// CopyObject and UploadPartCopy on Media's S3 surface (ADR-031): a server-side copy reads the source with the same key
++// (a zone of its environment it may read) and writes the destination through the one commit path (quota, holds,
++// deletion of the previous object). COPY keeps the source's type and metadata, REPLACE takes the request's; a copy
++// onto itself must change something; the copy-source conditions hold; UploadPartCopy takes byte ranges.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-s3c-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods');
++ process.env.FILES_PATH = dir('files');
++ process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails');
++ process.env.OV_NETWORK_URL = 'https://openvibe.network';
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
++ process.env.MEDIA_S3_KEY_SECRET = 'k'.repeat(40); // a test value: 32+ characters
++ process.env.MEDIA_S3_HOSTS = '127.0.0.1';
++
++ const db = require('../server/db/database');
++ const auth = require('../server/auth');
++ const usage = require('../server/s3/usage');
++ const { serviceAuth, zones: zoneContracts, ids } = require('openvibe-contracts');
++ const express = require('express');
++ const S3 = require('@aws-sdk/client-s3');
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ auth._setNetworkPublicKeyForTests(keys.publicKey);
++ const now = Math.floor(Date.now() / 1000);
++ const svc = (sub, cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap, iat: now, exp: now + 600, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
++ const ZONE_TOKEN = svc('svc:zone', ['media.zone.provision']);
++ const SERVICES_TOKEN = svc('svc:services', ['media.access_key.manage']);
++ const ADMIN = `usr_${ids.ulid()}`;
++ const PRJ = `prj_${ids.ulid()}`;
++
++ const s3router = require('../server/s3/routes');
++ const admin = require('../server/s3/admin-routes');
++ const app = express();
++ app.use((req, res, next) => (s3router.isS3Host(req) ? s3router(req, res, next) : next()));
++ app.use('/internal/zones', admin.internalRouter());
++ app.use('/api/v1/projects/:project/access-keys', admin.keysRouter({ projects: { isAdmin: async (p, s) => s === ADMIN && p === PRJ } }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const port = server.address().port;
++ const internal = async (method, p, { token, body } = {}) => {
++ const res = await fetch(`http://localhost:${port}${p}`, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
++ return { status: res.status, body: await res.json().catch(() => null) };
++ };
++ const zoneRecord = (env, name, over = {}) => {
++ const z = { id: `zon_${ids.ulid()}`, project_id: PRJ, env, name, default: name === 'default', access: name === 'default' ? 'per-object' : 'private', state: 'active',
++ quota_bytes: null, revision: 1, created_at: new Date().toISOString(), ...over };
++ return Object.assign(z, zoneContracts.identity(z));
++ };
++ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
++ const md5 = (b) => crypto.createHash('md5').update(b).digest('hex');
++
++ try {
++ const assets = zoneRecord('production', 'assets');
++ const backup = zoneRecord('production', 'backup');
++ const tiny = zoneRecord('production', 'tiny', { quota_bytes: 64 });
++ const sandboxZone = zoneRecord('sandbox', 'assets');
++ for (const z of [zoneRecord('production', 'default'), assets, backup, tiny, zoneRecord('sandbox', 'default'), sandboxZone]) {
++ assert.strictEqual((await internal('PUT', `/internal/zones/${z.id}`, { token: ZONE_TOKEN, body: z })).status, 200);
++ }
++ const issue = async (body) => (await internal('POST', `/api/v1/projects/${PRJ}/access-keys`, { token: SERVICES_TOKEN, body: { on_behalf_of: ADMIN, env: 'production', ...body } })).body;
++ const full = await issue({ name: 'full' });
++ const backupOnly = await issue({ name: 'backup only', zones: [backup.id] });
++ const reader = await issue({ name: 'reader', permissions: 'read' });
++ const client = (k) => new S3.S3Client({ endpoint: `http://127.0.0.1:${port}`, region: 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: k.key.id, secretAccessKey: k.secret } });
++ const s3 = client(full);
++ // An answer's Cache-Control is always private, no-store (the edge never keeps one): the stored value is read here.
++ const storedCache = async (zone, key) => {
++ const row = await db.get("SELECT metadata FROM media_objects WHERE zone_id = ? AND object_key = ? AND lifecycle_status = 'ready'", [zone.id, key]);
++ const md = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
++ return md.cache_control;
++ };
++ const read = async (Bucket, Key, zone = assets) => {
++ const o = await s3.send(new S3.GetObjectCommand({ Bucket, Key }));
++ return { body: Buffer.from(await o.Body.transformToByteArray()), type: o.ContentType, meta: o.Metadata, cache: await storedCache(zone, Key), etag: o.ETag };
++ };
++
++ // ── CopyObject: COPY keeps the source's type and metadata ──
++ const body = crypto.randomBytes(4000);
++ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'docs/report.pdf', Body: body, ContentType: 'application/pdf', CacheControl: 'max-age=60', Metadata: { owner: 'finance' } }));
++ let out = await s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'docs/report-copy.pdf', CopySource: 'assets/docs/report.pdf' }));
++ assert.strictEqual(out.CopyObjectResult.ETag, `"${md5(body)}"`, 'the copy\'s ETag is the MD5 of its bytes');
++ assert.ok(out.CopyObjectResult.LastModified instanceof Date);
++ let got = await read('assets', 'docs/report-copy.pdf');
++ assert.deepStrictEqual([sha(got.body), got.type, got.meta, got.cache], [sha(body), 'application/pdf', { owner: 'finance' }, 'max-age=60'], 'bytes, type and metadata copied');
++ out = await s3.send(new S3.CopyObjectCommand({ Bucket: 'backup', Key: '2026/report.pdf', CopySource: encodeURIComponent('/assets/docs/report.pdf') }));
++ assert.strictEqual(sha((await read('backup', '2026/report.pdf', backup)).body), sha(body), 'across zones, with a leading slash and URL-encoded');
++ console.log('✅ CopyObject (COPY): bytes, type, metadata and ETag; across zones; both copy-source spellings');
++
++ // ── REPLACE takes the request's; a copy onto itself must change something ──
++ await s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'docs/report-copy.pdf', CopySource: 'assets/docs/report.pdf', MetadataDirective: 'REPLACE', ContentType: 'application/octet-stream', Metadata: { owner: 'ops' } }));
++ got = await read('assets', 'docs/report-copy.pdf');
++ assert.deepStrictEqual([got.type, got.meta, got.cache], ['application/octet-stream', { owner: 'ops' }, undefined], 'REPLACE: the request\'s type and metadata');
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'docs/report.pdf', CopySource: 'assets/docs/report.pdf' })), /InvalidRequest/, 'a copy onto itself changing nothing is refused');
++ await s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'docs/report.pdf', CopySource: 'assets/docs/report.pdf', MetadataDirective: 'REPLACE', ContentType: 'application/pdf', Metadata: { owner: 'finance', reviewed: 'yes' } }));
++ got = await read('assets', 'docs/report.pdf');
++ assert.deepStrictEqual([sha(got.body), got.meta], [sha(body), { owner: 'finance', reviewed: 'yes' }], 'onto itself with REPLACE: the metadata changes, the bytes stay');
++ const versions = await db.all("SELECT lifecycle_status FROM media_objects WHERE zone_id = ? AND object_key = 'docs/report.pdf'", [assets.id]);
++ assert.deepStrictEqual(versions.map((v) => v.lifecycle_status).sort(), ['deleted', 'ready'], 'the previous object went through the deletion path');
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'x', CopySource: 'assets/docs/report.pdf', MetadataDirective: 'MERGE' })), /InvalidArgument/);
++ console.log('✅ REPLACE; a copy onto itself must change something, and then replaces through the deletion path');
++
++ // ── Conditions, missing sources, scopes and permissions ──
++ const etag = `"${md5(body)}"`;
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c1', CopySource: 'assets/docs/report.pdf', CopySourceIfMatch: '"0000"' })), /PreconditionFailed/);
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c1', CopySource: 'assets/docs/report.pdf', CopySourceIfNoneMatch: etag })), /PreconditionFailed/);
++ await s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c1', CopySource: 'assets/docs/report.pdf', CopySourceIfMatch: etag }));
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c2', CopySource: 'assets/docs/report.pdf', CopySourceIfModifiedSince: new Date(Date.now() + 3600e3) })), /PreconditionFailed/);
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c2', CopySource: 'assets/nope.pdf' })), /NoSuchKey/);
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c2', CopySource: 'assets' })), /InvalidArgument/);
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c2', CopySource: 'nowhere/docs/report.pdf' })), /NoSuchBucket/);
++ await assert.rejects(client(backupOnly).send(new S3.CopyObjectCommand({ Bucket: 'backup', Key: 'stolen.pdf', CopySource: 'assets/docs/report.pdf' })), /AccessDenied/, 'a key scoped to its zone cannot read another as the source');
++ await assert.rejects(client(reader).send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'c3', CopySource: 'assets/docs/report.pdf' })), /AccessDenied/, 'a read key cannot write the copy');
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'tiny', Key: 'big', CopySource: 'assets/docs/report.pdf' })), /QuotaExceeded/, 'the destination\'s quota holds');
++ console.log('✅ copy-source conditions; missing sources and buckets; zone scopes, read-only keys and quotas hold');
++
++ // ── UploadPartCopy: byte ranges into a multipart upload ──
++ const big = crypto.randomBytes(6 * 1024 * 1024 + 123);
++ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'big.bin', Body: big }));
++ const { UploadId } = await s3.send(new S3.CreateMultipartUploadCommand({ Bucket: 'backup', Key: 'big-copy.bin' }));
++ const FIVE = 5 * 1024 * 1024;
++ const p1 = await s3.send(new S3.UploadPartCopyCommand({ Bucket: 'backup', Key: 'big-copy.bin', UploadId, PartNumber: 1, CopySource: 'assets/big.bin', CopySourceRange: `bytes=0-${FIVE - 1}` }));
++ assert.strictEqual(p1.CopyPartResult.ETag, `"${md5(big.subarray(0, FIVE))}"`);
++ const p2 = await s3.send(new S3.UploadPartCopyCommand({ Bucket: 'backup', Key: 'big-copy.bin', UploadId, PartNumber: 2, CopySource: 'assets/big.bin', CopySourceRange: `bytes=${FIVE}-${big.length - 1}` }));
++ await assert.rejects(s3.send(new S3.UploadPartCopyCommand({ Bucket: 'backup', Key: 'big-copy.bin', UploadId, PartNumber: 3, CopySource: 'assets/big.bin', CopySourceRange: `bytes=0-${big.length}` })), /InvalidArgument/, 'a range past the source is refused');
++ await s3.send(new S3.CompleteMultipartUploadCommand({ Bucket: 'backup', Key: 'big-copy.bin', UploadId,
++ MultipartUpload: { Parts: [{ PartNumber: 1, ETag: p1.CopyPartResult.ETag }, { PartNumber: 2, ETag: p2.CopyPartResult.ETag }] } }));
++ assert.strictEqual(sha((await read('backup', 'big-copy.bin', backup)).body), sha(big), 'the parts copied by range make the source again');
++ const { UploadId: u2 } = await s3.send(new S3.CreateMultipartUploadCommand({ Bucket: 'backup', Key: 'small.bin' }));
++ const whole = await s3.send(new S3.UploadPartCopyCommand({ Bucket: 'backup', Key: 'small.bin', UploadId: u2, PartNumber: 1, CopySource: 'assets/docs/report.pdf' }));
++ assert.strictEqual(whole.CopyPartResult.ETag, etag, 'without a range, the whole source is the part');
++ await assert.rejects(s3.send(new S3.UploadPartCopyCommand({ Bucket: 'backup', Key: 'small.bin', UploadId: 'mpu_01JZ0000000000000000000000', PartNumber: 1, CopySource: 'assets/docs/report.pdf' })), /NoSuchUpload/);
++ console.log('✅ UploadPartCopy: ranges, the whole source, a range past the end refused, completes to the source\'s bytes');
++
++ // ── A copy counts as a write of the destination zone ──
++ usage._reset();
++ await s3.send(new S3.CopyObjectCommand({ Bucket: 'backup', Key: 'counted.pdf', CopySource: 'assets/docs/report.pdf' }));
++ await new Promise((r) => setImmediate(r));
++ const counted = [...usage._pending().values()];
++ assert.ok(counted.some((b) => b.zone.id === backup.id && b.capability === 'media.object.upload' && b.unit === 'requests' && b.quantity === 1), 'one write in the destination zone');
++ assert.ok(!counted.some((b) => b.zone.id === assets.id), 'the source zone is not charged a read');
++ console.log('✅ a copy counts as one write of the destination zone');
++ } finally {
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('s3 copies: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/s3-zones.test.js b/test/s3-zones.test.js
iindex b1e021f..e74d416 100644
---- a/test/s3-zones.test.js
++++ b/test/s3-zones.test.js
@@@ -256,10 +256,9 @@ const { Readable } = require('stream');
await model.placeHold({ object_id: current.id, kind: 'evidence', reason: 'test' });
await assert.rejects(s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'readme.txt', Body: 'v3' })), /AccessDenied/, 'an overwrite of a held object is refused');
await assert.rejects(s3.send(new S3.DeleteObjectCommand({ Bucket: 'assets', Key: 'readme.txt' })), /AccessDenied/, 'so is its deletion');
-- await assert.rejects(s3.send(new S3.UploadPartCopyCommand({ Bucket: 'assets', Key: 'mp', UploadId: (await s3.send(new S3.CreateMultipartUploadCommand({ Bucket: 'assets', Key: 'mp' }))).UploadId, PartNumber: 1, CopySource: 'assets/readme.txt' })), /NotImplemented/);
-- await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'copy', CopySource: 'assets/readme.txt' })), /NotImplemented/);
++ await assert.rejects(s3.send(new S3.CopyObjectCommand({ Bucket: 'assets', Key: 'copy', CopySource: 'assets/readme.txt?versionId=3' })), /NotImplemented/, 'zones keep no versions');
await assert.rejects(s3.send(new S3.DeleteBucketCommand({ Bucket: 'assets' })), /NotImplemented|MethodNotAllowed/);
-- console.log('✅ quotas, overwrite through the deletion path, holds refuse overwrite and delete, NotImplemented answers (copy, part copy, bucket deletion)');
++ console.log('✅ quotas, overwrite through the deletion path, holds refuse overwrite and delete, NotImplemented answers (versions, bucket deletion)');
// ── Deleting objects ──
await s3.send(new S3.DeleteObjectCommand({ Bucket: 'assets', Key: 'site/index.html' }));