/** * S3 operations — upload, download, list, delete. * * VLT-5: All operations now accept an EntityContext (entity-aware bucket + * STS-scoped credentials) instead of reading static env config. The caller * is responsible for resolving the context via resolveEntityContext(). */ import * as fs from "fs"; import type { EntityContext } from "./types.js"; import { type PutPrecondition } from "./object-io.js"; import { toPosixKey } from "./posix-key.js"; /** * Author identity stamped onto S3 user-defined metadata at upload time. The * vault UI's "CREATED BY" column reads `Metadata['created-by']` back via * HEAD; uploads without an author leave that column blank. */ export interface UploadAuthor { /** Cognito sub — stable join key for per-member rollups. */ userSub: string; /** Email for human display. */ email: string; } /** * S3 user-metadata header that marks an object as a symlink record. * The value is now an OPAQUE MARKER ('1') — the target lives in the * object body. Earlier drafts of this feature stored the target in * metadata (raw, then base64), but S3 user-metadata is HTTP-header- * bound: total ≤ 2 KiB across all user-defined keys + values. A * sufficiently long POSIX target (or one with author-metadata * adding to the total) would exceed the limit and PutObject would * reject the upload outright. Moving the target to the body — which * has no such limit — makes target length bounded only by S3's 5 GB * object size cap. The metadata header still serves as the read- * time discriminator (cheaper than peeking at body bytes via HEAD). * * Backward compat: downloadFile prefers the body (sliced after * SYMLINK_BODY_PREFIX) for the target string. If the body doesn't * carry the prefix (a legacy upload from earlier in this PR's * lifetime), it falls back to base64-decoding the metadata value — * the round-trip-validating decoder returns raw or decoded as * appropriate. Any prior in-flight upload still resolves correctly. */ export declare const SYMLINK_TARGET_META_KEY = "hq-symlink-target"; /** * Constant value written to SYMLINK_TARGET_META_KEY. Any non-empty * string would work as a discriminator — '1' is just compact and * conventional for boolean flags in HTTP headers. */ export declare const SYMLINK_MARKER_META_VALUE = "1"; /** * Encode a target for the S3 metadata header value. Retained as the * legacy encoder so a downloader can still receive it and round-trip * via decodeSymlinkMetadataValue, but new uploads use * SYMLINK_MARKER_META_VALUE — the target lives in the body now. */ export declare function encodeSymlinkMetadataValue(target: string): string; /** * Decode a target from the S3 metadata header value. Used as a * legacy fallback when the body doesn't carry SYMLINK_BODY_PREFIX * (i.e. an in-flight upload from earlier in this PR before the * marker-only metadata convention). Round-trip-validates: if the * value isn't valid base64 of UTF-8, returns the raw string. */ export declare function decodeSymlinkMetadataValue(value: string): string; /** * Magic prefix prepended to symlink-record bodies on the wire. Two * properties this gives us: * * 1. ETag distinguishability. S3 ETag = MD5(body). Without a prefix, * a symlink whose target string equals some regular file's exact * contents would produce the same ETag, and the LIST-based pull * planner (which can't see per-object metadata) would classify a * symlink ↔ regular-file transition as "no change" and never * replace the local representation. The prefix makes those two * shapes ETag-distinguishable for the realistic case (collision * now requires a regular file whose contents *start* with this * prefix, which is implausible for any non-malicious source). * * 2. Fallback discriminator. If user-metadata is ever lost (S3 * cross-region replication of object data only, manual S3 console * copy that drops Metadata), the body prefix lets a downloader * recover the symlink record without needing the metadata header. * We don't currently rely on this fallback — the metadata header * is still the primary discriminator on the read path — but the * prefix keeps the option open and avoids painting us into a * "metadata is the only signal" corner. * * Format: `hq-symlink:` + target string (UTF-8 bytes). No trailing * newline. The colon separates the marker from the target so a future * extension can encode additional fields if needed. */ export declare const SYMLINK_BODY_PREFIX = "hq-symlink:"; /** * S3 user-metadata key carrying the source-side file mode (permission bits * only — \`mode & 0o777\`) as an octal string ("755", "640", etc.). On * download, downloadFile parses this with \`parseInt(value, 8)\` and chmods * the file to the exact source mode after the byte write. * * Bug #5 in the 5.33.0 deep-test was originally reported as "exec bit lost * on sync" but the verification report broadened it: ALL modes (0600 / 0640 * / 0700 / 0750 / 0755) collapsed to the receiver's umask default (0644) * because no mode signal crossed the wire at all. Stamping the mode in * metadata is the smallest schema change that preserves the full * permission bitfield without a per-host umask negotiation. * * Symlinks: skipped at upload time (symlink mode is OS-controlled * lrwxrwxrwx) and skipped on download (\`fs.chmodSync\` follows symlinks * and would mutate the target's mode instead). * * Back-compat: legacy uploads have no \`hq-mode\` header — the receiver * leaves the umask default in place, matching pre-fix behavior. */ export declare const FILE_MODE_META_KEY = "hq-mode"; /** * S3 user-metadata key carrying the source-side file modification time * (mtimeMs) as an integer-millisecond epoch string ("1700000000000"). On * download, downloadFile parses this with a strict-numeric regex BEFORE * parseInt, then applies it via `fs.utimesSync(localPath, mtimeDate, * mtimeDate)` after the byte write. * * 5.37.0 symmetric to the 5.34.0 Bug #5 mode preservation: a file's * modification time should follow it across machines instead of resetting * to "the time of sync." Without this metadata, every receiver's mtime is * wall-clock-now at write-time — making "newer than" comparisons, * mtime-keyed caches, and reproducible builds break across sync. * * Symlinks: skipped at upload time (symlink mtime is OS-controlled and * `lstat` on a symlink already returns the symlink's own times — but we * don't stamp them because the symlink record wire body is `hq-symlink:` * + target string, not real file content, so its mtime isn't user- * meaningful) and skipped on download (`fs.utimesSync` follows symlinks * and would mutate the target's mtime instead; `lutimesSync` is not in * stable Node). * * Composition with 5.36.0 lstat fast-path: the journal stamp is captured * AFTER utimesSync runs (the share/sync call sites lstat AFTER * downloadFile returns), so the journal's mtimeMs matches the post-utimes * lstat. The next sync's fast-path correctly skips re-hashing. * * Clock skew: a peer with a wrong clock pushes file with mtimeMs=; * receivers apply . This is the same trade git's "file from the * future" warning makes — silent in our case, deliberately. Clock skew * is the user's problem, not the sync engine's. * * Back-compat: legacy uploads (pre-5.37.0) have no `hq-mtime` header — * the receiver leaves the on-disk mtime at write-time, matching pre- * 5.37.0 behavior. Forward-compat: pre-5.37.0 pullers ignore `hq-mtime` * and keep their current "mtime = write-time" behavior. Both work; only * the receiver upgrade unlocks the feature. */ export declare const FILE_MTIME_META_KEY = "hq-mtime"; /** * S3 user-metadata key carrying the plaintext SHA-256 for a regular file. * * This is content lineage, not an S3 version token: SSE-KMS gives identical * plaintext different ETags, so an ETag cannot answer whether two copies have * the same bytes. The sync planner already owns this digest; uploadFile only * transports it and never hashes a file for metadata. * * Symlinks deliberately carry no content metadata. Their wire format is a * target record with different identity rules, and keeping every regular-file * stamp out of that format preserves the existing asymmetry. */ export declare const FILE_CONTENT_SHA256_META_KEY = "hq-content-sha256"; /** Return a valid published regular-file digest, otherwise no lineage signal. */ export declare function publishedContentHash(metadata: Record | undefined): string | undefined; export declare function setUploadContentHash(localPath: string, contentHash: string): void; /** * S3 user-metadata key carrying the source-side file birthtime (birthtimeMs) * as an integer-millisecond epoch string. Stamped on upload ONLY when * `birthtimeMs > 0 && birthtimeMs !== mtimeMs` — many filesystems (Linux * ext4 historically, tmpfs, some FUSE mounts) return 0 (unsupported) or * the same value as mtime (no separate creation time tracking). The * filter keeps the metadata header free of noise on those platforms. * * Pull: NO-OP for now. Node has no API to set birthtime on POSIX as of * v24 (no `lbirthtime`, no `birthtimeSync`). The push side stamps it * anyway so a future receiver upgrade — once Node lands the API — can * apply it without a server-side data migration. * * Symlinks: skipped on both sides for the same reasons as `hq-mtime`. */ export declare const FILE_BTIME_META_KEY = "hq-btime"; /** * Encode/decode the symlink wire body. Kept as exported helpers so the * format is centrally defined and tests can probe both sides without * duplicating the prefix string. */ export declare function encodeSymlinkBody(target: string): Buffer; export declare function sweepStaleStagedFiles(dir: string, maxAgeMs?: number, nowMs?: number): string[]; export interface CreateStagedSymlinkOps { platform: NodeJS.Platform; symlink(target: string, linkPath: string, type?: fs.symlink.Type): void; statIsDirectory(absPath: string): boolean | undefined; } /** * A file-flavored NTFS symlink could not be created because this Windows host * lacks SeCreateSymbolicLinkPrivilege (Developer Mode is off and the process is * not elevated). Raised ONLY from createStagedSymlink's win32 file leg, and * ONLY for an EPERM (Windows ERROR_PRIVILEGE_NOT_HELD) from the file-flavored * symlink syscall — a directory overlay never reaches this leg (it becomes an * unprivileged junction, the HQ-DESKTOP-3M fix). Deliberately NOT triggered by * EACCES: that reports an ACL-denied destination, a genuine filesystem- * permission fault that Developer Mode cannot fix and that must stay loud * rather than be downgraded to a silent skip. Every other errno, and EACCES, * keep propagating untyped. * * This is a per-object, machine-local condition that is permanent until the * privilege state changes, not a transfer failure: the link's TARGET already * exists locally as a regular file (that is the only shape this leg * materializes), so no content is lost — only the alias is missing. The pull * loop downgrades it to a deliberate, non-fatal skip so one unmaterializable * alias never errors the whole company or flips the one-shot runner to a * non-zero exit code (Sentry HQ-DESKTOP-54). */ export declare class WindowsSymlinkPrivilegeError extends Error { /** The symlink target from the vault record (the relative wire path). */ readonly target: string; /** The underlying errno from the refused syscall (always EPERM). */ readonly code: string | undefined; constructor(target: string, code: string | undefined); } /** * Fixed, content-free remediation shown when a Windows symlink record is * skipped for lack of SeCreateSymbolicLinkPrivilege. Carries no host path, * argv, or file content — the same content-safety envelope as `not-shipped`. * Lives here (beside the error) so every materialization call site — the pull * download, the pull conflict probe, and the push conflict mirror — shares one * source of truth without a sync.ts <-> share.ts import cycle. */ export declare const WINDOWS_SYMLINK_PRIVILEGE_REMEDY: string; /** * A staged install/preserve rename was refused by Windows with a transient * sharing-violation (EPERM / ERROR_ACCESS_DENIED, or EBUSY). * * This is the SECOND, untouched per-object EPERM path that reopened Sentry * HQ-DESKTOP-54 after the symlink-privilege fix (PR #328) landed. A Windows * rename is refused with EPERM whenever the source or destination handle is * held by another process without FILE_SHARE_DELETE — antivirus, the Search * indexer, OneDrive, or an open editor — the classic transient sharing * violation. Like WindowsSymlinkPrivilegeError this is a per-object, * machine-local condition, NOT a transfer failure: the downloaded bytes are * intact in the staged temp file and the operator's local body is untouched. * The pull/push leg downgrades it to a deliberate, non-fatal skip so one held * file never errors the whole company or flips the one-shot runner to * PARTIAL_SYNC_EXIT (2); the object is left unjournaled so the very next sync * pass materializes it once the file is released — that pass, not a retry, is * the recovery mechanism. EACCES (an ACL-denied destination) is deliberately * NOT downgraded — skipping it would hide a genuine fault — so it and every * other errno keep propagating untyped. */ export declare class WindowsRenameBlockedError extends Error { /** The rename destination (diagnostic only; never emitted to an event). */ readonly target: string; /** The underlying errno from the refused syscall (EPERM or EBUSY). */ readonly code: string | undefined; /** The refused syscall — always a rename. */ readonly syscall: string; constructor(target: string, code: string | undefined); } /** * Fixed, content-free remediation shown when a Windows rename is skipped for a * transient sharing violation. Carries no host path, argv, or file content — * the same content-safety envelope as WINDOWS_SYMLINK_PRIVILEGE_REMEDY. */ export declare const WINDOWS_RENAME_BLOCKED_REMEDY: string; /** Injectable seams for renameOrRaiseWindowsBlocked (platform/rename in tests). */ export interface RenameGuardOps { platform?: NodeJS.Platform; rename?: (from: string, to: string) => void; } /** * Rename `from` → `to`, converting a transient Windows sharing-violation into * the typed WindowsRenameBlockedError so callers can downgrade it to a * deliberate skip. * * On every non-win32 platform this is a STRAIGHT PASS-THROUGH to the injected * rename: no new error type, byte-identical error object and timing. On win32 a * refusal is surfaced IMMEDIATELY on the first attempt — there is deliberately * NO retry and no synchronous backoff: * - the pull download runs objects concurrently, so a synchronous wait here * would block the whole runner's event loop (and a large held tree would * freeze it for the sum of every per-object wait); * - a retry widens the window in which a destination the caller already * snapshot-checked could be edited and released mid-wait, silently * overwriting that edit. * The unjournaled skip already converges on the next pass, so an immediate * refusal is both safer and cheaper (see the two Codex review findings on * PR #436). EACCES and every other errno propagate IMMEDIATELY and unchanged — * only the transient sharing-violation family (EPERM/EBUSY) becomes the typed * refusal. */ export declare function renameOrRaiseWindowsBlocked(from: string, to: string, ops?: RenameGuardOps): void; /** * Create a fully-staged symlink without requiring Windows Developer Mode. * NTFS symlinks fail with EPERM without SeCreateSymbolicLinkPrivilege, while * directory junctions need no privilege but require an absolute target. HQ * vault links are directory overlays, so missing targets default to junctions. * See Sentry HQ-DESKTOP-3M. */ export declare function createStagedSymlink(target: string, linkPath: string, ops?: CreateStagedSymlinkOps): void; export interface ReplaceStagedPathOps { lstat(path: string): fs.Stats; rename(from: string, to: string): void; remove(path: string): void; } /** * Install a fully-staged download over its destination. * * A direct rename is atomic and remains the fast path for absent destinations * and ordinary files. Windows cannot rename over an existing directory link, * though, because MoveFileEx treats that link as an existing directory. For a * symlink/junction destination, move the old link to a sibling backup first, * install the staged entry, then remove the backup. Any failure after the * backup move restores the exact prior link before rethrowing. * * The tiny operations seam keeps the Windows failure/rollback contract * deterministic in ESM tests without spying on non-configurable fs exports. */ export declare function replaceStagedPath(stagedPath: string, localPath: string, ops?: ReplaceStagedPathOps): void; /** * Batch pre-mint transport URLs for `keys` under `op` so the subsequent * per-file transfer calls (downloadFile/headRemoteFile/…) reuse them instead * of presigning one key at a time. On the presigned-URL transport this turns * an N-file leg from N presign requests into ceil(N/100) — the difference * between completing a bulk pull and 429ing past the 100-req/hr limit. No-op * on the S3 SDK transport (which has no presign step) and harmless if called * with an empty list. Best-effort: a prime failure never propagates — the * per-file path falls back to a single presign. * * Call it once, right before a transfer loop, with the full key set the loop * will touch. The presigned transport memoizes one IO instance per company for * the run, so the warmed cache is the same one the loop drains. */ export declare function primeObjectTransport(ctx: EntityContext, op: "get" | "put" | "delete", keys: string[]): Promise; /** * One upload's identity for {@link primeUploads}: the vault key, the local * path (to lstat for mode/mtime), whether it's a symlink, and the author. */ export interface UploadPrimeItem { key: string; localPath: string; isSymlink: boolean; /** Existing planner digest; never calculate one while priming. */ contentHash?: string; author?: UploadAuthor; } /** * Batch pre-mint PUT URLs (+ the created-at HEADs they depend on) for a set of * uploads, signing the SAME metadata uploadFile/uploadSymlink would compute so * the transfer loop can replay the cached headers. Turns an N-file push from * ~N presign calls (1 per PUT, sometimes 2-3 with HEADs) into ceil(N/1000) GET * + ceil(N/1000) PUT — the difference between completing a bulk push and 429ing * past the 100/hr limit. No-op on the S3 SDK transport; best-effort. * * The per-item created-at HEADs run over the GET cache primed first, so they * cost S3 round-trips but NO extra presign calls (not counted against 100/hr). */ export declare function primeUploads(ctx: EntityContext, items: UploadPrimeItem[]): Promise; export { toPosixKey }; export type VaultKeyScope = "company" | "personal"; export interface VaultKeyValidationIssue { code: string; message: string; } /** * Classify a vault key with the same rules as {@link validateVaultUploadKey} * without throwing. Callers that enumerate remote objects can use this before * presigning so permanently-invalid legacy keys remain benign skips. */ export declare function classifyVaultKey(key: string, _scope: VaultKeyScope): VaultKeyValidationIssue | null; export declare function validateVaultUploadKey(key: string, scope: VaultKeyScope): void; export declare function uploadFile(ctx: EntityContext, localPath: string, key: string, author?: UploadAuthor, precondition?: PutPrecondition): Promise<{ etag: string; }>; /** * Upload a symlink as a zero-byte object whose user metadata carries the * link's target string. Mirrors uploadFile's signature so callers can pick * the right primitive once they've classified the entry as link vs file. * * The target string is stored verbatim — whatever fs.readlinkSync returned. * Relative targets transfer cleanly across machines; absolute targets are * preserved as-is and may be broken on a destination that doesn't share * the source's $HOME layout. Cross-machine portability of absolute targets * is out of scope for this primitive — the policy decision lives in the * caller (currently: upload anyway, never silently rewrite). */ export declare function uploadSymlink(ctx: EntityContext, target: string, key: string, author?: UploadAuthor, precondition?: PutPrecondition): Promise<{ etag: string; }>; /** * Download an object to localPath and return its S3 user-metadata. * * Materializes regular files and symlink records (the symlink branch * reconstructs the link from the body/marker). The GetObject response * already carries `response.Metadata` (S3 lowercases keys), so we * return it to callers — e.g. the pull loop reads `created-by` to * attribute downloaded files to their author with zero extra network. */ export interface DownloadModeWarning { /** The mode guardrail that could not be applied exactly. */ reason: "missing-hq-mode" | "invalid-hq-mode" | "chmod-failed"; /** Legacy objects can retain a local file's known-good permission bits. */ fallback?: "preserved-local-mode" | "receiver-default"; } /** * A download whose parent directory exists as a DANGLING symlink. * * The vault stores directory symlinks as first-class objects (e.g. * `companies//.obsidian -> ../../.obsidian`) AND stores files beneath them. * When the link's target is absent on this machine, the child write fails — * and it fails as ENOENT rather than EEXIST, because `mkdir(2)` returns EEXIST * for the link and Node's `recursive: true` implementation then stats it, which * fails on a dangling link. Raw, that surfaced as a hard error that marked the * WHOLE company `errored`/`partial` on every sync cycle (2026-07-24 dogfood box: * `.obsidian/hotkeys.json: ENOENT ... mkdir '.../companies/indigo/.obsidian'`). * * It is a per-object condition, not a company-level failure, so it is typed and * the pull loop skips the object loudly instead of failing the run. We do NOT * materialize the link target: the target is by definition outside the * directory being synced, and creating it would write through a path the * caller's containment guard deliberately refuses. */ export declare class DanglingSymlinkParentError extends Error { readonly key: string; readonly dir: string; constructor(key: string, dir: string); } export declare function downloadFile(ctx: EntityContext, key: string, localPath: string, options?: { beforeReplace?: () => void; /** * Platform override so win32 symlink semantics are directly testable * on any host (same convention as local-path-codec.ts). */ win32?: boolean; }): Promise<{ metadata?: Record; contentHash?: string; contentSize?: number; /** Non-fatal mode guardrail warnings for caller telemetry. */ modeWarnings?: DownloadModeWarning[]; }>; export interface RemoteFile { key: string; size: number; lastModified: Date; etag: string; /** Omitted by legacy ObjectIO implementations means STANDARD. */ storageClass?: string; } /** * Share paged LIST conversion between streaming and array callers. Neither * retains preceding pages unless its caller explicitly collects them. Array * callers iterate each page synchronously to avoid per-object promise costs. */ export type RemoteListPageWait = (work: Promise) => Promise; export declare function streamRemoteFilePages(ctx: EntityContext, prefix?: string, waitPage?: RemoteListPageWait): AsyncGenerator; export declare function streamRemoteFiles(ctx: EntityContext, prefix?: string, waitPage?: RemoteListPageWait): AsyncGenerator; export declare function listRemoteFiles(ctx: EntityContext, prefix?: string): Promise; export declare function deleteRemoteFile(ctx: EntityContext, key: string): Promise; /** * Check if a remote key exists and return its metadata. */ export declare function headRemoteFile(ctx: EntityContext, key: string): Promise<{ lastModified: Date; etag: string; size: number; metadata?: Record; storageClass?: string; } | null>; /** * Read an exact modern symlink record without materializing it on disk. * * Marker cleanup is destructive, so it requires both wire discriminators: * the metadata marker and the body prefix. Ordinary downloads remain more * permissive for backward compatibility, but callers using this helper are * proving that an object is safe to remove as a stale symlink marker. */ export declare function readVerifiedRemoteSymlinkTarget(ctx: EntityContext, key: string): Promise; //# sourceMappingURL=s3.d.ts.map