@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
862 lines • 33.8 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { callerSignal, currentCaller } from "../../context/session_context.js";
import { ABORT_JOIN_MS, runCancellable, hasAborted, makeAbortError } from "../../utils/abort.js";
import { MoveError } from "../../errors/types.js";
import { CONDITION_SUPPORT } from "./constants.js";
import { ByteSpool } from "../../io/spool.js";
import { chunks } from "../../io/cooperative.js";
import {} from "../../io/types.js";
import { toIsoZ } from "../../utils/dates.js";
import { VFSName } from "../../types.js";
import { eacces, enoent, enotsup, unnamedFsError } from "../../errors/fs.js";
import { rstripSlash } from "../../utils/slash.js";
import { ABSENT_WRITE, ANY_WRITE, OwnRead, } from "../../cache/types.js";
import {} from "../object_store/driver.js";
import { ConditionLostError } from "../object_store/errors.js";
import { createS3Client, isConditionLost, isDenied, isNotFound, loadS3Module, streamToBuffer, } from "./client.js";
import { CONDITION_LOST_CODES, MAX_PARTS, PART_DOUBLING, PART_SIZE, SCOPE_ERROR, } from "./constants.js";
const DELETE_BATCH = 1000;
function isoOf(modified) {
if (modified instanceof Date)
return toIsoZ(modified);
return typeof modified === 'string' ? modified : '';
}
async function* listPages(conn, input) {
let continuationToken;
do {
const page = { ...input };
if (continuationToken !== undefined)
page.ContinuationToken = continuationToken;
const resp = (await conn.send(new conn.mod.ListObjectsV2Command(page)));
yield resp;
continuationToken = resp.IsTruncated === true ? resp.NextContinuationToken : undefined;
} while (continuationToken !== undefined);
}
function keyPrefixOf(accessor) {
return accessor.config.keyPrefix ?? '';
}
async function connect(accessor) {
const { config } = accessor;
const mod = await loadS3Module(config);
const client = await createS3Client(config);
const send = client.send.bind(client);
return {
conn: { send, mod, config },
close: () => {
;
client.destroy?.();
return Promise.resolve();
},
};
}
async function* listChildren(conn, pfx) {
const input = { Bucket: conn.config.bucket, Prefix: pfx, Delimiter: '/' };
for await (const page of listPages(conn, input)) {
for (const cp of page.CommonPrefixes ?? []) {
const p = cp.Prefix;
if (p === undefined)
continue;
const child = rstripSlash(p);
if (child !== '')
yield { key: child, kind: 'd' };
else
yield { key: p, kind: 'marker' };
}
for (const obj of page.Contents ?? []) {
const key = obj.Key;
if (key === undefined)
continue;
const relative = key.slice(pfx.length);
if (relative !== '' && !relative.includes('/')) {
yield { key, kind: 'f', size: obj.Size ?? null, modified: isoOf(obj.LastModified) };
}
else {
yield { key, kind: 'marker' };
}
}
}
}
async function* listSubtree(conn, stem) {
// The prefix listing also matches sibling keys sharing the stem as a
// name prefix ("data-old" under stem "data"), so each key is checked
// against the exact stem or the slashed subtree.
const base = stem !== '' ? `${stem}/` : '';
for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: stem })) {
for (const obj of page.Contents ?? []) {
const okey = obj.Key;
if (okey === undefined)
continue;
if (!(okey === stem || okey.startsWith(base)))
continue;
yield { key: okey, size: obj.Size ?? 0, modified: isoOf(obj.LastModified) };
}
}
}
// Quote-stripped ETag from a head or put response, '' when absent.
// Returned as a possibly-empty string rather than `string | null`
// because `head` needs both spellings: `fingerprint` drops an empty one
// while `extra` omits the key entirely.
function etagOf(resp) {
return resp.ETag?.replace(/^"|"$/g, '') ?? '';
}
// VersionId from a head or put response, null when unversioned.
function versionOf(resp) {
const revision = resp.VersionId ?? null;
return revision === 'null' ? null : revision;
}
async function head(conn, key) {
let resp;
try {
resp = (await conn.send(new conn.mod.HeadObjectCommand({ Bucket: conn.config.bucket, Key: key })));
}
catch (err) {
if (isNotFound(err))
return null;
if (isDenied(err))
throw denied(key);
throw err;
}
const etag = etagOf(resp);
return {
size: resp.ContentLength ?? null,
modified: resp.LastModified === undefined ? null : toIsoZ(resp.LastModified),
fingerprint: etag !== '' ? etag : null,
revision: versionOf(resp),
extra: etag !== '' ? { etag } : {},
};
}
async function get(conn, key) {
return (await getVersioned(conn, key, null))?.[0] ?? null;
}
function put(conn, key, data) {
return putIf(conn, key, data, ANY_WRITE);
}
// Tokens are stored unquoted (`etagOf`); the wire takes the quoted form.
function quoted(token) {
return token.startsWith('"') ? token : `"${token}"`;
}
function condition(cond) {
switch (cond.kind) {
case 'any':
return {};
case 'absent':
return { IfNoneMatch: '*' };
case 'match':
return { IfMatch: quoted(cond.version) };
}
}
/**
* Copy `srcKey` onto `dstKey` within the bucket, under the destination's
* condition and the version the source must still have. Mirrors Python's
* `_copy_object`.
*/
async function copyObject(conn, srcKey, dstKey, cond = null, source = null) {
const response = await conn.send(new conn.mod.CopyObjectCommand({
Bucket: conn.config.bucket,
CopySource: `${conn.config.bucket}/${srcKey}`,
Key: dstKey,
...(source !== null ? { CopySourceIfMatch: source } : {}),
...(cond !== null ? condition(cond) : {}),
}));
const copied = response.CopyObjectResult;
const metadata = response.$metadata;
const fingerprint = copied?.ETag?.replace(/^"|"$/g, '');
return {
fingerprint: fingerprint === undefined || fingerprint === '' ? null : fingerprint,
revision: versionOf(response),
retried: (metadata?.attempts ?? 1) > 1,
};
}
function isMissingBucket(err) {
const e = err;
return e.name === 'NoSuchBucket' || e.Code === 'NoSuchBucket';
}
/**
* Whether a conditioned request lost its condition: a version sent with
* `If-Match` loses on a 412, and on a 404 for its key (not a missing bucket),
* since AWS answers that way for a key deleted since it was read. Mirrors
* Python's `_lost_condition`.
*/
function lostCondition(err, matched) {
return isConditionLost(err) || (matched && isNotFound(err) && !isMissingBucket(err));
}
async function guarded(key, call, matched = false) {
try {
return await call;
}
catch (err) {
if (lostCondition(err, matched))
throw new ConditionLostError([key], { gone: !isConditionLost(err) });
throw err;
}
}
async function putIf(conn, key, data, cond, size = data instanceof Uint8Array ? data.byteLength : 0) {
// The ETag is read through the same helper `head` uses, so the token a
// write stamps and the token a later stat reports are one spelling.
// A write carries no type of its own, so the mount's default is the one the
// store keeps and serves back.
if (cond.kind !== 'any' && conn.config.presignedUrlProvider !== undefined)
throw enotsup('s3', 'conditional put', key);
const contentType = conn.config.defaultContentType;
const resp = (await guarded(key, conn.send(new conn.mod.PutObjectCommand({
Bucket: conn.config.bucket,
Key: key,
Body: data,
...(data instanceof Uint8Array ? {} : { ContentLength: size }),
...(contentType !== undefined && contentType !== '' ? { ContentType: contentType } : {}),
...condition(cond),
})), cond.kind === 'match'));
const etag = etagOf(resp);
return {
size,
fingerprint: etag !== '' ? etag : null,
revision: versionOf(resp),
};
}
async function putStream(conn, key, data, cond) {
// Presigned fetch has no multipart API; custom endpoints keep conditional PUT.
if (conn.config.presignedUrlProvider !== undefined ||
(cond.kind !== 'any' && conn.config.endpoint !== undefined && conn.config.endpoint !== '')) {
const spool = new ByteSpool();
try {
await spool.fill(data);
if (spool.size > 5 * 1024 ** 3)
throw new Error('s3: single PUT exceeds 5 GiB');
const body = await spool.body(conn.config.presignedUrlProvider === undefined ? 'sdk' : 'fetch');
return await putIf(conn, key, body, cond, spool.size);
}
finally {
await spool.close();
}
}
let upload = null;
const parts = [];
// TODO(MIRAGE-38): spool parts past 64 MiB; this holds a whole part.
let buffer = new Uint8Array(PART_SIZE);
let used = 0;
let size = 0;
const args = { Bucket: conn.config.bucket, Key: key };
const sendPart = async (body) => {
if (parts.length >= MAX_PARTS)
throw new Error('s3: streamed upload exceeds 10000 parts');
const number = parts.length + 1;
const response = await conn.send(new conn.mod.UploadPartCommand({
...args,
UploadId: upload,
PartNumber: number,
Body: body,
}));
if (typeof response.ETag !== 'string')
throw new Error('s3: upload part returned no ETag');
parts.push({ PartNumber: number, ETag: response.ETag });
};
try {
for await (const chunk of chunks(data)) {
size += chunk.byteLength;
let offset = 0;
while (offset < chunk.byteLength) {
const count = Math.min(buffer.length - used, chunk.byteLength - offset);
buffer.set(chunk.subarray(offset, offset + count), used);
offset += count;
used += count;
if (used === buffer.length) {
if (upload === null) {
const contentType = conn.config.defaultContentType;
const opened = await conn.send(new conn.mod.CreateMultipartUploadCommand({
...args,
...(contentType !== undefined && contentType !== ''
? { ContentType: contentType }
: {}),
}));
if (typeof opened.UploadId !== 'string')
throw new Error('s3: upload returned no id');
upload = opened.UploadId;
}
// Each send ends before the buffer fills again, so it goes as is.
await sendPart(buffer);
used = 0;
const part = PART_SIZE * 2 ** Math.floor(parts.length / PART_DOUBLING);
if (part > buffer.length)
buffer = new Uint8Array(part);
}
}
}
if (upload === null) {
return await putIf(conn, key, buffer.slice(0, used), cond);
}
if (used > 0)
await sendPart(buffer.subarray(0, used));
const response = (await guarded(key, conn.send(new conn.mod.CompleteMultipartUploadCommand({
...args,
UploadId: upload,
MultipartUpload: { Parts: parts },
...condition(cond),
})), cond.kind === 'match'));
upload = null;
return { size, fingerprint: etagOf(response) || null, revision: versionOf(response) };
}
finally {
if (upload !== null) {
try {
await conn.send(new conn.mod.AbortMultipartUploadCommand({ ...args, UploadId: upload }));
}
catch (error) {
console.debug('aborting multipart upload failed:', key, error);
}
}
}
}
async function getVersioned(conn, key, revision) {
let resp;
try {
resp = (await conn.send(new conn.mod.GetObjectCommand({
Bucket: conn.config.bucket,
Key: key,
...(revision !== null ? { VersionId: revision } : {}),
})));
}
catch (err) {
if (isNotFound(err)) {
if (revision === null)
return null;
if (isMissingBucket(err))
throw err;
// A pinned revision gone from the store is no file, not absence.
throw enoent(key);
}
if (revision === null && isDenied(err))
throw denied(key);
throw err;
}
const etag = etagOf(resp);
return [await streamToBuffer(resp.Body), etag !== '' ? etag : null];
}
async function copyIf(conn, srcKey, dstKey, cond) {
try {
await copyObject(conn, srcKey, dstKey, cond);
}
catch (err) {
// A 404 is lost only while the source is still there.
let lost = isConditionLost(err);
let probeFailed = false;
if (!lost && cond.kind === 'match' && isNotFound(err) && !isMissingBucket(err)) {
try {
lost = (await head(conn, srcKey)) !== null;
}
catch (probeErr) {
probeFailed = true;
console.debug(`source probe failed for ${srcKey}: ${String(probeErr)}`);
}
}
if (lost)
throw new ConditionLostError([dstKey], { gone: !isConditionLost(err) });
if (probeFailed || !isNotFound(err) || isMissingBucket(err))
throw err;
console.debug(`copy source ${srcKey} is missing: ${String(err)}`);
return false;
}
return true;
}
async function deleteIf(conn, key, cond) {
await guarded(key, conn.send(new conn.mod.DeleteObjectCommand({
Bucket: conn.config.bucket,
Key: key,
...condition(cond),
})));
}
/**
* Which end of a refused copy changed: S3 answers 412 for either condition,
* so the destination is looked up, and one no longer at the version sent is
* the end that lost; otherwise the source's pin did. Only a refusal pays for
* the lookup. Returns the key that changed and the version it lost on, ABSENT
* when it is gone. Mirrors Python's `_copy_loser`.
*/
async function copyLoser(conn, srcKey, dstKey, cond, source) {
try {
if (cond.kind === 'match') {
const meta = await head(conn, dstKey);
if (meta === null)
return [dstKey, OwnRead.ABSENT];
if (quoted(meta.fingerprint ?? '') !== quoted(cond.version)) {
return [dstKey, quoted(cond.version)];
}
}
if ((await head(conn, srcKey)) === null)
return [srcKey, OwnRead.ABSENT];
}
catch (err) {
// Unknown which end changed: the source is named, and both keep their versions.
console.debug(`copy loser lookup failed for ${dstKey}: ${String(err)}`);
}
return [srcKey, quoted(source)];
}
/**
* A refusal of `key`, keeping the version it lost on unless it is gone
* (ABSENT). Mirrors Python's `_lost_on`.
*/
function lostOn(key, version, landed = false) {
if (version === OwnRead.ABSENT)
return new ConditionLostError([key], { landed, gone: true });
return new ConditionLostError([key], { landed, versions: new Map([[key, quoted(version)]]) });
}
function deleteRejected(err) {
const e = err;
return ((e.name === 'PreconditionFailed' || e.Code === 'PreconditionFailed') &&
(e.$metadata?.attempts ?? 1) === 1);
}
/** Join an owned move request within the caller's cancellation grace. */
function joinMove(request, signal) {
return runCancellable(request, signal, ABORT_JOIN_MS);
}
async function recoverCopy(conn, dstKey, receipt, absent) {
if (receipt.revision !== null && receipt.retried)
return [false, null, 'copy was retried; earlier copied versions may remain'];
let condition;
if (receipt.revision !== null)
condition = { VersionId: receipt.revision };
else if (absent && receipt.fingerprint !== null)
condition = { IfMatch: quoted(receipt.fingerprint) };
else
return [false, null, absent ? 'copy identity unavailable' : 'destination was not confirmed new'];
const controller = new AbortController();
try {
const cleanup = conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: dstKey, ...condition }), { abortSignal: controller.signal });
await joinMove(cleanup, callerSignal() ?? currentCaller().stop?.signal);
}
catch (err) {
if (isNotFound(err) && !isMissingBucket(err))
return [true, null, null];
console.debug('recovering a failed move copy failed:', err);
return [false, err, 'conditional cleanup failed'];
}
finally {
controller.abort();
}
return [true, null, null];
}
/** Give cancellation cleanup its own bounded lifetime. */
async function recoverCancelledCopy(conn, dstKey, receipt, absent) {
const [recovered] = await recoverCopy(conn, dstKey, receipt, absent);
if (!recovered)
console.warn('S3 move cancelled; destination cleanup incomplete');
}
async function moveFileIf(conn, srcKey, dstKey, cond, source) {
// S3 compensation intentionally differs from GNU's cross-device mv.
let pinned = source;
if (pinned === null) {
const meta = await head(conn, srcKey);
if (meta === null)
return false;
pinned = meta.fingerprint ?? '';
}
const match = quoted(pinned);
let destination = cond;
if (cond.kind === 'any') {
try {
if ((await head(conn, dstKey)) === null)
destination = ABSENT_WRITE;
}
catch (err) {
if (err.code !== 'EACCES')
throw err;
console.debug('move destination inspection refused; recovery needs a version ID');
}
}
let receipt;
try {
receipt = await copyObject(conn, srcKey, dstKey, destination, match);
}
catch (err) {
if (!lostCondition(err, true))
throw err;
if (destination.kind === 'absent') {
let observed = null;
try {
observed = await head(conn, dstKey);
}
catch (probeError) {
console.debug('copy destination probe failed:', probeError);
}
if (observed !== null)
throw new ConditionLostError([dstKey]);
}
const [named, version] = await copyLoser(conn, srcKey, dstKey, destination, match);
throw lostOn(named, version);
}
const signal = callerSignal() ?? currentCaller().stop?.signal;
if (hasAborted(signal)) {
await recoverCancelledCopy(conn, dstKey, receipt, destination.kind === 'absent');
throw makeAbortError(signal);
}
const controller = new AbortController();
let sourceRemoved = false;
try {
const deletion = conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: srcKey, IfMatch: match }), { abortSignal: controller.signal });
await joinMove(deletion, signal);
sourceRemoved = true;
if (hasAborted(signal))
throw makeAbortError(signal);
}
catch (err) {
if (hasAborted(signal)) {
if (deleteRejected(err))
await recoverCancelledCopy(conn, dstKey, receipt, destination.kind === 'absent');
else if (!sourceRemoved)
console.warn('S3 move cancelled; source deletion outcome unknown, destination may remain');
throw makeAbortError(signal);
}
if (!lostCondition(err, true)) {
if (err.name === 'AbortError')
throw err;
throw new MoveError(err, 'source deletion outcome is unknown');
}
const gone = !isConditionLost(err);
const [recovered, recoveryError, recoveryReason] = deleteRejected(err)
? await recoverCopy(conn, dstKey, receipt, destination.kind === 'absent')
: [false, null, 'source deletion outcome is unknown'];
if (hasAborted(signal)) {
if (!recovered)
console.warn('S3 move cancelled; destination cleanup incomplete');
throw makeAbortError(signal);
}
throw new ConditionLostError([srcKey], {
landed: true,
gone,
...(gone ? {} : { versions: new Map([[srcKey, match]]) }),
recovered,
recoveryError,
recoveryReason,
});
}
finally {
controller.abort();
}
return true;
}
/**
* Each listing page under `pfx`, every key with the version it is measured
* against: the version the agent read where there is one, else the
* listing's, one page at a time.
* Mirrors Python's `_known_pages`.
*/
async function* knownPages(conn, pfx, known) {
for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: pfx })) {
const listed = [];
for (const obj of page.Contents ?? []) {
if (obj.Key !== undefined)
listed.push([obj.Key, quoted(obj.ETag ?? '')]);
}
if (listed.length === 0)
continue;
const versions = await known(listed.map(([key]) => key));
yield listed.map(([key, token]) => {
const version = versions.get(key);
return [key, version !== undefined ? quoted(version) : token];
});
}
}
/**
* The error for a key the store refuses this caller (403). Unnamed, as a
* kernel's EACCES is: the key carries the mount's prefix, so the layer above
* names the operand. Mirrors Python's `_denied`.
*/
function denied(key) {
return unnamedFsError('EACCES', `S3 refused '${key}'`);
}
/**
* The error for keys a DeleteObjects refused in the body of its 200. A key
* is not a path, so it names none and the command names its operand.
* Mirrors Python's `_delete_refused`.
*/
function deleteRefused(failed) {
const message = `S3 refused to delete ${String(failed.length)} object(s), starting at '${failed[0] ?? ''}'`;
return unnamedFsError('EACCES', message);
}
/**
* Delete each listed key only while it is the version listed, returning the
* keys a newer write changed (kept) and the keys the store refused for
* another reason. A refusal comes back per key in the body of a 200.
*/
async function deleteBatch(conn, keys) {
const lost = [];
const failed = [];
for (let start = 0; start < keys.length; start += DELETE_BATCH) {
const batch = keys.slice(start, start + DELETE_BATCH);
const sent = new Map(batch);
const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({
Bucket: conn.config.bucket,
Delete: { Objects: batch.map(([Key, ETag]) => ({ Key, ETag })) },
})));
for (const err of resp.Errors ?? []) {
const key = err.Key ?? '';
if (CONDITION_LOST_CODES.has(err.Code ?? ''))
lost.push([key, sent.get(key) ?? OwnRead.ABSENT]);
else
failed.push(key);
}
}
return [lost, failed];
}
/**
* Throw a walk's later failure, carrying the keys it lost before it (as a
* ConditionLostError), or the failure itself when none were. Mirrors Python's
* `_raise_lost_before`.
*/
function raiseLostBefore(lost, err, untouched) {
if (lost.length > 0) {
throw new ConditionLostError(lost.map(([key]) => key), { versions: new Map([...untouched, ...lost]), error: err });
}
throw err;
}
/**
* Raise for the keys a prefix op kept: lost keys keep the versions they were
* measured on; a key the store refused for another reason is the error
* reported, the lost keys carried with it. Mirrors Python's `_raise_kept`.
*/
function raiseKept(lost, failed, untouched) {
if (lost.length > 0) {
throw new ConditionLostError(lost.map(([key]) => key), {
versions: new Map([...untouched, ...lost]),
error: failed.length > 0 ? deleteRefused(failed) : null,
});
}
if (failed.length > 0)
throw deleteRefused(failed);
}
async function deletePrefixIf(conn, pfx, known) {
const lost = [];
const failed = [];
try {
for await (const listed of knownPages(conn, pfx, known)) {
const [pageLost, pageFailed] = await deleteBatch(conn, listed);
for (const item of pageLost)
lost.push(item);
for (const item of pageFailed)
failed.push(item);
}
}
catch (err) {
raiseLostBefore(lost, err, new Map());
}
raiseKept(lost, failed, new Map());
}
async function movePrefixIf(conn, srcPfx, dstPfx, known, dstKnown) {
let found = false;
const lost = [];
const failed = [];
const moved = [];
const untouched = new Map();
try {
for await (const listed of knownPages(conn, srcPfx, known)) {
found = true;
const dstOf = (key) => `${dstPfx}${key.slice(srcPfx.length)}`;
const held = await dstKnown(listed.map(([key]) => dstOf(key)));
for (const [key, token] of listed) {
const dstKey = dstOf(key);
const heldVersion = held.get(dstKey);
const cond = heldVersion !== undefined && heldVersion !== ''
? { kind: 'match', version: heldVersion }
: ANY_WRITE;
try {
await copyObject(conn, key, dstKey, cond, token);
}
catch (err) {
if (!lostCondition(err, true))
throw err;
if (cond.kind === 'match') {
const loser = await copyLoser(conn, key, dstKey, cond, token);
lost.push(loser);
// The end left alone keeps its version, as a file mv's.
if (loser[0] === key)
untouched.set(dstKey, quoted(cond.version));
else
untouched.set(key, quoted(token));
}
else {
// A source gone (404) keeps what the session last saw of it.
lost.push([key, isConditionLost(err) ? token : OwnRead.ABSENT]);
}
continue;
}
moved.push([key, token]);
}
}
// As any failed copy, a lost one keeps the whole source: deleting the
// rest would split the directory across both ends.
if (lost.length === 0) {
// A batch at a time, so a later batch's failure keeps what one lost.
for (let start = 0; start < moved.length; start += DELETE_BATCH) {
const [batchLost, batchFailed] = await deleteBatch(conn, moved.slice(start, start + DELETE_BATCH));
for (const item of batchLost)
lost.push(item);
for (const item of batchFailed)
failed.push(item);
}
}
}
catch (err) {
raiseLostBefore(lost, err, untouched);
}
raiseKept(lost, failed, untouched);
return found;
}
async function deleteFile(conn, key) {
await conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: key }));
}
async function deletePrefix(conn, pfx) {
for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: pfx })) {
const keys = (page.Contents ?? [])
.map((obj) => obj.Key)
.filter((k) => k !== undefined)
.map((k) => ({ Key: k }));
if (keys.length > 0) {
const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({
Bucket: conn.config.bucket,
Delete: { Objects: keys },
})));
// A refused key comes back in the body of a 200.
const failed = (resp.Errors ?? []).map((e) => e.Key ?? '');
if (failed.length > 0)
throw deleteRefused(failed);
}
}
}
async function copyFile(conn, srcKey, dstKey) {
try {
await copyObject(conn, srcKey, dstKey);
}
catch (err) {
if (!isNotFound(err) || isMissingBucket(err))
throw err;
console.debug(`copy source ${srcKey} is missing: ${String(err)}`);
return false;
}
return true;
}
async function moveFile(conn, srcKey, dstKey) {
// The source is classified before anything is copied rather than by
// letting CopyObject fail: stores disagree about a missing source (S3
// and MinIO even spell the code differently, and a lenient
// S3-compatible store accepts the copy and writes nothing), and on that
// last one an error-driven fallback would delete a source whose copy
// never landed. Only a classified not-found answers false; every other
// failure propagates rather than reading as a directory.
try {
await conn.send(new conn.mod.HeadObjectCommand({ Bucket: conn.config.bucket, Key: srcKey }));
}
catch (err) {
if (isDenied(err))
throw denied(srcKey);
if (!isNotFound(err))
throw err;
return false;
}
if (!(await copyFile(conn, srcKey, dstKey)))
throw enoent(srcKey);
await conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: srcKey }));
return true;
}
/**
* Relocate every key under `srcPfx` to the matching key under `dstPfx`.
*
* A directory is a key prefix plus the empty marker object mkdir writes,
* and listing on the prefix returns both, so one walk moves the marker
* and the whole subtree together. Returns whether any key was found
* under the source prefix.
*/
async function movePrefix(conn, srcPfx, dstPfx) {
const { bucket } = conn.config;
const moved = [];
for await (const page of listPages(conn, { Bucket: bucket, Prefix: srcPfx })) {
for (const obj of page.Contents ?? []) {
if (obj.Key === undefined)
continue;
await copyObject(conn, obj.Key, `${dstPfx}${obj.Key.slice(srcPfx.length)}`);
moved.push({ Key: obj.Key });
}
}
if (moved.length === 0)
return false;
// Deleted only after every copy landed: a partial move that dropped the
// source would lose the entries that had not been copied yet.
const failed = [];
for (let start = 0; start < moved.length; start += DELETE_BATCH) {
const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: moved.slice(start, start + DELETE_BATCH) },
})));
// DeleteObjects reports a refused key in the body of a 200, so a
// response that throws nothing can still have deleted nothing.
// Ignoring it would leave the source tree in place beside the copy
// and call the move a success.
for (const err of resp.Errors ?? [])
failed.push(err.Key ?? '');
}
if (failed.length > 0) {
// Both trees survive, which is what GNU mv leaves behind when the
// unlink half fails after the copy half succeeded. EACCES because a
// refused delete is a lock or a policy in practice, and because it is
// an fs error: mv reports the operand and keeps going instead of
// aborting the whole command line.
throw eacces(`/${srcPfx}`, `S3 refused to delete ${String(failed.length)} source object(s) after ` +
`copying, starting at '${failed[0] ?? ''}'`);
}
return true;
}
async function probePrefix(conn, pfx) {
let resp;
try {
resp = (await conn.send(new conn.mod.ListObjectsV2Command({
Bucket: conn.config.bucket,
Prefix: pfx,
Delimiter: '/',
MaxKeys: 1,
})));
}
catch (err) {
if (isDenied(err))
throw denied(pfx);
throw err;
}
return (resp.CommonPrefixes ?? []).length > 0 || (resp.Contents ?? []).length > 0;
}
export const DRIVER = {
vfs: VFSName.S3,
conditionSupport: CONDITION_SUPPORT,
scopeError: SCOPE_ERROR,
keyPrefixOf,
connect,
listChildren,
listSubtree,
head,
get,
put,
deleteFile,
deletePrefix,
moveFile,
movePrefix,
copyFile,
probePrefix,
putIf,
putStream,
getVersioned,
copyIf,
deleteIf,
moveFileIf,
movePrefixIf,
deletePrefixIf,
isNotFound: isNotFound,
};
//# sourceMappingURL=driver.js.map