diff --git a/src/docs/src/FS/copy.md b/src/docs/src/FS/copy.md index 8873036f3..37bf7c020 100755 --- a/src/docs/src/FS/copy.md +++ b/src/docs/src/FS/copy.md @@ -18,11 +18,13 @@ puter.fs.copy(options) #### `source` (String) (Required) -The path to the file or directory to copy. +The path or UID of the file or directory to copy. #### `destination` (String) (Required) -The path to the destination directory. If destination is a directory then the file or directory will be copied into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error. +The path or UID of the destination directory. If destination is a directory then the file or directory will be copied into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error. + +Relative paths resolve against the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name. #### `options` (Object) (Optional) diff --git a/src/docs/src/FS/delete.md b/src/docs/src/FS/delete.md index 15656344e..2763bd324 100755 --- a/src/docs/src/FS/delete.md +++ b/src/docs/src/FS/delete.md @@ -18,8 +18,8 @@ puter.fs.delete(options) #### `paths` (String | String[]) (required) -A single path or array of paths of the file(s) or directory(ies) to delete. -If a path is not absolute, it will be resolved relative to the app's root directory. +A single path or array of paths of the file(s) or directory(ies) to delete. A UID can be given in place of a path. +If a path is not absolute, it will be resolved relative to the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name. #### `options` (Object) (optional) diff --git a/src/docs/src/FS/move.md b/src/docs/src/FS/move.md index 3691b57ed..02d6e29b5 100755 --- a/src/docs/src/FS/move.md +++ b/src/docs/src/FS/move.md @@ -18,11 +18,15 @@ puter.fs.move(options) #### `source` (String) (Required) -The path to the file or directory to move. +The path or UID of the file or directory to move. #### `destination` (String) (Required) -The path to the destination directory. If destination is a directory then the file or directory will be moved into that directory using the same name as the source file or directory. If the destination is a file, we overwrite if overwrite is `true`, otherwise we error. +The path to the destination directory, or the item's new path. If destination is a directory then the file or directory will be moved into that directory using the same name as the source file or directory. Otherwise the item is moved into the destination's parent directory under its last path component; if a file is already there, we overwrite if overwrite is `true`, otherwise we error. A UID can be given in place of a path, and must name a directory. + +Relative paths resolve against the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name. + +Without `newName`, the destination is looked up first to tell the two cases apart. If that lookup fails for any reason other than the destination not existing (for example, access is denied), the promise rejects with that error. #### `options` (Object) (Optional) diff --git a/src/docs/src/FS/read.md b/src/docs/src/FS/read.md index c184108a2..8ef9cc72a 100755 --- a/src/docs/src/FS/read.md +++ b/src/docs/src/FS/read.md @@ -18,8 +18,8 @@ puter.fs.read(options) #### `path` (String) (required) -Path of the file to read. -If `path` is not absolute, it will be resolved relative to the app's root directory. +Path of the file to read, or its UID. +If `path` is not absolute, it will be resolved relative to the app's root directory. A relative name that looks like a UID is read as one; prefix it with `./` to address a file by that name. #### `options` (Object) (optional) diff --git a/src/docs/src/FS/readdir.md b/src/docs/src/FS/readdir.md index 26895ff2e..543566d95 100755 --- a/src/docs/src/FS/readdir.md +++ b/src/docs/src/FS/readdir.md @@ -43,7 +43,7 @@ A `Promise` that resolves to an array of [`FSItem`](/Objects/fsitem/) objects (f Each item carries `is_shared`: `true` when it has been shared with someone, `false` when it has not, and `null` for items that are not yours. Only shares on the item itself count — the children of a folder you shared report `false`, since the share lives on the folder. Use [`getShares()`](/FS/getShares/) on an item to see who can reach it, including access inherited from a parent. -When the request includes `cursor` (even `null`) or `includeTotal`, the promise instead resolves to a page object: +When the request includes `cursor` (even `null`) or `includeTotal`, or is `recursive` with a `limit` or `offset`, the promise instead resolves to a page object: - `items` (Array): The [`FSItem`](/Objects/fsitem/) objects on this page. - `cursor` (String) (optional): Present while more pages exist; pass it to the next call. diff --git a/src/puter-js/src/modules/FileSystem/operations/copy.js b/src/puter-js/src/modules/FileSystem/operations/copy.js index 7b3a3b81a..3279729c8 100644 --- a/src/puter-js/src/modules/FileSystem/operations/copy.js +++ b/src/puter-js/src/modules/FileSystem/operations/copy.js @@ -1,4 +1,4 @@ -import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; /** @typedef {import('../types.js').CopyOptions} CopyOptions */ @@ -6,8 +6,8 @@ import { defineOperation, firstDefined } from './scaffold.js'; /** * Copies a file or directory to another location. Relative paths resolve - * against the app's root directory. When `destination` is a directory the item - * is copied into it under the same name. + * against the app's root directory; a UID-shaped string is read as a uid. When + * `destination` is a directory the item is copied into it under the same name. * * @type {{ * (options: CopyOptions): Promise, @@ -29,8 +29,8 @@ const copy = defineOperation({ // No socket at all for a client that opted out (`puter.socketEnabled`). original_client_socket_id: this.socket?.id, socket_id: this.socket?.id, - source: getAbsolutePathForApp(options.source), - destination: getAbsolutePathForApp(options.destination), + source: getAbsolutePathOrUidForApp(options.source), + destination: getAbsolutePathOrUidForApp(options.destination), overwrite: options.overwrite, new_name: firstDefined(options, 'newName', 'new_name'), // if user is copying an item to where its source is, change the name so there is no conflict diff --git a/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js b/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js index a0ccebb58..5696f8a9a 100644 --- a/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js +++ b/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js @@ -1,11 +1,11 @@ -import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; /** @typedef {import('../types.js').DeleteOptions} DeleteOptions */ /** * Deletes one or more files or directories. Relative paths resolve against the - * app's root directory. + * app's root directory; a UID-shaped string is read as a uid. * * Named `deleteFSEntry` rather than `delete` because `delete` is a reserved * keyword; it is exposed as `puter.fs.delete`. @@ -29,7 +29,7 @@ const deleteFSEntry = defineOperation({ return { endpoint: '/delete', body: { - paths: paths.map((path) => getAbsolutePathForApp(path)), + paths: paths.map((path) => getAbsolutePathOrUidForApp(path)), descendants_only: firstDefined(options, 'descendantsOnly', 'descendants_only') ?? false, recursive: options.recursive ?? true, }, diff --git a/src/puter-js/src/modules/FileSystem/operations/getShareLink.js b/src/puter-js/src/modules/FileSystem/operations/getShareLink.js index c40957734..3862d44d4 100644 --- a/src/puter-js/src/modules/FileSystem/operations/getShareLink.js +++ b/src/puter-js/src/modules/FileSystem/operations/getShareLink.js @@ -1,10 +1,9 @@ +import { looksLikeUid } from '../utils/getAbsolutePathForApp.js'; import { parseOperationArgs } from './scaffold.js'; import stat from './stat.js'; /** @typedef {import('../types.js').GetShareLinkOptions} GetShareLinkOptions */ -const UUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; - /** * @typedef {{ * (options: GetShareLinkOptions): Promise, @@ -44,7 +43,7 @@ const getShareLinkImpl = async function (...args) { const item = typeof options.item === 'string' ? options.item : undefined; const uid = options.uid !== undefined ? String(options.uid) - : (item !== undefined && UUID.test(item) ? item : undefined); + : (looksLikeUid(item) ? item : undefined); const path = uid === undefined ? (options.path ?? item) : undefined; if ( uid === undefined && (typeof path !== 'string' || path === '') ) { return fail({ message: 'getShareLink() needs a path or a uid.', code: 'field_missing' }); diff --git a/src/puter-js/src/modules/FileSystem/operations/move.js b/src/puter-js/src/modules/FileSystem/operations/move.js index 5a2b6f655..559bd18a6 100644 --- a/src/puter-js/src/modules/FileSystem/operations/move.js +++ b/src/puter-js/src/modules/FileSystem/operations/move.js @@ -1,5 +1,5 @@ import path from 'path-browserify'; -import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { getAbsolutePathOrUidForApp, looksLikeUid } from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; import stat from './stat.js'; @@ -8,9 +8,9 @@ import stat from './stat.js'; /** * Moves a file or directory to another location. Relative paths resolve - * against the app's root directory. When `destination` is a directory the item - * is moved into it under the same name; otherwise the last path component is - * used as the new name. + * against the app's root directory; a UID-shaped string is read as a uid. When + * `destination` is a directory the item is moved into it under the same name; + * otherwise the last path component is used as the new name. * * @type {{ * (options: MoveOptions): Promise, @@ -26,19 +26,24 @@ import stat from './stat.js'; const move = defineOperation({ positional: ['source', 'destination'], async request (options) { - const source = getAbsolutePathForApp(options.source); - let destination = getAbsolutePathForApp(options.destination); + const source = getAbsolutePathOrUidForApp(options.source); + let destination = getAbsolutePathOrUidForApp(options.destination); let newName = firstDefined(options, 'newName', 'new_name'); - if ( ! newName ) { - // Whether the destination names a directory to move into, or the - // new path of the item itself. + // A path destination is either a directory to move into or the item's + // new path; a uid can only be a directory, so it needs no lookup. + if ( ! newName && ! looksLikeUid(destination) ) { let destinationIsDir = false; try { const destStats = await stat.call(this, destination); destinationIsDir = Boolean(destStats.is_dir); } catch (e) { - // Destination doesn't exist — treat it as the new path. + // Only a missing destination is a new path. A denied or failed + // lookup must not turn the move into a rename. + if ( e?.code !== 'subject_does_not_exist' ) { + if ( typeof options.error === 'function' ) options.error(e); + throw e; + } } if ( ! destinationIsDir ) { newName = path.basename(destination); diff --git a/src/puter-js/src/modules/FileSystem/operations/operations.test.js b/src/puter-js/src/modules/FileSystem/operations/operations.test.js index e135d9f6e..d745cb101 100644 --- a/src/puter-js/src/modules/FileSystem/operations/operations.test.js +++ b/src/puter-js/src/modules/FileSystem/operations/operations.test.js @@ -205,6 +205,44 @@ describe('move', () => { create_missing_parents: true, }); }); + + it('treats a missing destination as the new path', async () => { + FakeXHR.respondWith = (body, xhr) => { + if ( ! xhr.url.endsWith('/stat') ) return { success: true }; + xhr.status = 404; + return { message: 'Subject does not exist', code: 'subject_does_not_exist' }; + }; + await fs.move('/a/file.txt', '/b/renamed.txt'); + expect(lastRequest().url).toBe('https://api.test/move'); + expect(lastBody()).toMatchObject({ destination: '/b', new_name: 'renamed.txt' }); + }); + + it('rejects without moving when the destination lookup is denied', async () => { + const denied = { message: 'Access denied', code: 'access_denied' }; + FakeXHR.respondWith = (body, xhr) => { + xhr.status = 403; + return denied; + }; + const error = vi.fn(); + await expect(fs.move('/a/file.txt', '/b/hidden', undefined, undefined, error)) + .rejects.toEqual(denied); + expect(error).toHaveBeenCalledWith(denied); + expect(FakeXHR.requests.map(xhr => xhr.url)).toEqual(['https://api.test/stat']); + }); + + it('moves into a uid destination without a lookup', async () => { + const dirUid = '0f8fad5b-d9cb-469f-a165-70867728950e'; + await fs.move('/a/file.txt', dirUid); + expect(FakeXHR.requests).toHaveLength(1); + expect(lastBody()).toMatchObject({ source: '/a/file.txt', destination: dirUid }); + expect(lastBody().new_name).toBeUndefined(); + }); + + it('sends a uid source through unchanged', async () => { + const uid = '0f8fad5b-d9cb-469f-a165-70867728950e'; + await fs.move({ source: uid, destination: '/b', newName: 'x.txt' }); + expect(lastBody()).toMatchObject({ source: uid, destination: '/b' }); + }); }); describe('mkdir', () => { @@ -221,6 +259,12 @@ describe('mkdir', () => { }); }); + it('resolves a UID-shaped relative name as a path', async () => { + const name = '0f8fad5b-d9cb-469f-a165-70867728950e'; + await fs.mkdir(name); + expect(lastBody()).toMatchObject({ parent: '~', path: name }); + }); + it('mkdir(path, options) applies the options', async () => { await fs.mkdir('/a/b', { dedupeName: true, createMissingParents: true }); expect(lastBody()).toMatchObject({ dedupe_name: true, create_missing_parents: true }); @@ -284,6 +328,14 @@ describe('read', () => { expect(lastRequest().url).toBe('https://api.test/read?file=%2Fa%2Ffile.txt&offset=4&byte_count=8'); }); + it('reads by uid, and by path when a UID-shaped name has a ./ prefix', async () => { + const uid = '0f8fad5b-d9cb-469f-a165-70867728950e'; + await fs.read(uid); + expect(lastRequest().url).toBe(`https://api.test/read?file=${uid}`); + await fs.read(`./${uid}`); + expect(lastRequest().url).toBe(`https://api.test/read?file=%7E%2F${uid}`); + }); + it('opts back into HTTP caching with { cache: true }', async () => { await fs.read('/a/file.txt', { cache: true }); expect(lastRequest().requestHeaders?.['Cache-Control']).toBeUndefined(); @@ -495,6 +547,22 @@ describe('stat', () => { expect(item.is_shared).toBe(false); }); + it('does not serve one uid\'s cached entry for another uid', async () => { + FakeXHR.respondWith = body => ({ uid: body.uid, name: `${body.uid}.txt`, is_dir: false }); + await fs.stat({ uid: 'uid-a' }); + FakeXHR.requests = []; + const item = await fs.stat({ uid: 'uid-b', consistency: 'eventual' }); + expect(FakeXHR.requests).toHaveLength(1); + expect(item.uid).toBe('uid-b'); + }); + + it('resolves a UID-shaped relative path as a path', async () => { + const name = '0f8fad5b-d9cb-469f-a165-70867728950e'; + await fs.stat(name); + expect(lastBody().path).toBe(`~/${name}`); + expect(lastBody().uid).toBeUndefined(); + }); + it('keeps a share-carrying result out of the cache', async () => { FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, shares: [] }); await fs.stat('/a/cached.txt', { returnShares: true }); diff --git a/src/puter-js/src/modules/FileSystem/operations/read.js b/src/puter-js/src/modules/FileSystem/operations/read.js index 8d8b65e56..02d5bd002 100644 --- a/src/puter-js/src/modules/FileSystem/operations/read.js +++ b/src/puter-js/src/modules/FileSystem/operations/read.js @@ -1,11 +1,12 @@ -import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { getAbsolutePathOrUidForApp } from '../utils/getAbsolutePathForApp.js'; import { defineOperation } from './scaffold.js'; /** @typedef {import('../types.js').ReadOptions} ReadOptions */ /** * Reads a file and resolves with its contents as a `Blob`. Relative paths - * resolve against the app's root directory. + * resolve against the app's root directory; a UID-shaped string is read as a + * uid. * * @type {{ * (options: ReadOptions): Promise, @@ -25,7 +26,7 @@ import { defineOperation } from './scaffold.js'; const read = defineOperation({ positional: ['path'], request (options) { - const query = new URLSearchParams({ file: getAbsolutePathForApp(options.path) }); + const query = new URLSearchParams({ file: getAbsolutePathOrUidForApp(options.path) }); if ( options.offset ) query.set('offset', String(options.offset)); if ( options.byte_count ) query.set('byte_count', String(options.byte_count)); diff --git a/src/puter-js/src/modules/FileSystem/operations/readdir.js b/src/puter-js/src/modules/FileSystem/operations/readdir.js index f7c36d640..b7c144df0 100644 --- a/src/puter-js/src/modules/FileSystem/operations/readdir.js +++ b/src/puter-js/src/modules/FileSystem/operations/readdir.js @@ -5,8 +5,10 @@ import mapV2EntryToV1 from '../utils/mapV2EntryToV1.js'; import { fsRequest, parseOperationArgs } from './scaffold.js'; /** @typedef {import('../types.js').ReaddirOptions} ReaddirOptions */ +/** @typedef {import('../types.js').ReaddirOptionsOwn} ReaddirOptionsOwn */ /** @typedef {import('../types.js').FSItemRead} FSItemRead */ /** @typedef {import('../../../lib/types.js').ListPage} FSItemPage */ +/** @typedef {import('../../../lib/types.js').RequestCallbacks} FSItemPageCallbacks */ // Listings larger than this are served but never cached. const MAX_CACHE_SIZE = 100 * 1024 * 1024; @@ -162,11 +164,31 @@ const readdirPaged = async function (options) { return await requestPromise; }; +/** + * Options that make readdir resolve with one `{items, cursor?, total?}` page: + * `cursor` or `includeTotal`, or a `recursive` listing bounded by `limit` or + * `offset` (recursive listings are always paged). + * + * @typedef {ReaddirOptionsOwn & FSItemPageCallbacks & ( + * | { cursor: string | null } + * | { includeTotal: true } + * | { recursive: true, limit: number } + * | { recursive: true, offset: number } + * )} ReaddirPageOptions + */ + /** * @typedef {{ * (options: ReaddirOptions & { stream: true }): AsyncIterableIterator, - * (options: ReaddirOptions & ({ cursor: string | null } | { includeTotal: true })): Promise, + * (options: ReaddirPageOptions): Promise, * (options: ReaddirOptions): Promise, + * (path: string, options: ReaddirOptions & { stream: true }): AsyncIterableIterator, + * ( + * path: string, + * options: ReaddirPageOptions, + * success?: (value: FSItemPage) => void, + * error?: (reason: unknown) => void, + * ): Promise, * ( * path: string, * options?: ReaddirOptions, @@ -186,8 +208,9 @@ const readdirPaged = async function (options) { * resolve against the app's root directory) or by `uid`. * * By default the whole listing is returned as an array. Passing `cursor` or - * `includeTotal` returns one `{items, cursor?, total?}` page instead, and - * `stream: true` returns an async iterator over those pages. + * `includeTotal`, or `limit`/`offset` with `recursive`, returns one + * `{items, cursor?, total?}` page instead, and `stream: true` returns an async + * iterator over those pages. * * @type {ReaddirOperation} */ diff --git a/src/puter-js/src/modules/FileSystem/operations/stat.js b/src/puter-js/src/modules/FileSystem/operations/stat.js index f0ab675f6..9c8b9a771 100644 --- a/src/puter-js/src/modules/FileSystem/operations/stat.js +++ b/src/puter-js/src/modules/FileSystem/operations/stat.js @@ -53,13 +53,14 @@ const statImpl = async function (...args) { options.consistency = 'strong'; } - // Generate cache key based on path or uid + // Only path-addressed entries are cached: path-based invalidation (socket + // events, share changes) can't reach an entry keyed by uid. let cacheKey; - if ( options.path ) { + if ( options.uid === undefined && options.path ) { cacheKey = `item:${ options.path}`; } - if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) { + if ( cacheKey && options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) { const cachedResult = await puter._cache.get(cacheKey); if ( cachedResult ) { return cachedResult; @@ -105,7 +106,7 @@ const statImpl = async function (...args) { result.shares = result.shares.map(toShare); } // Not cached — a later plain stat must not serve share data. - if ( ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) { + if ( cacheKey && ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) { puter._cache.set(cacheKey, result); } return result; diff --git a/src/puter-js/src/modules/FileSystem/utils/getAbsolutePathForApp.js b/src/puter-js/src/modules/FileSystem/utils/getAbsolutePathForApp.js index 86c7ab3f5..eed83cc03 100644 --- a/src/puter-js/src/modules/FileSystem/utils/getAbsolutePathForApp.js +++ b/src/puter-js/src/modules/FileSystem/utils/getAbsolutePathForApp.js @@ -1,5 +1,14 @@ import path from 'path-browserify'; +const reLooksLikeUUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; + +/** + * @param {unknown} value + * @returns {value is string} + */ +export const looksLikeUid = (value) => + typeof value === 'string' && reLooksLikeUUID.test(value); + const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => { // preserve previous behavior for falsy values when env is gui if ( puter.env === 'gui' && !relativePath ) @@ -7,12 +16,6 @@ const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => { return relativePath; } - const reLooksLikeUUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; - const isUUID = reLooksLikeUUID.test(relativePath); - if ( isUUID ) { - return relativePath; - } - // if no relative path is provided, use the current working directory if ( ! relativePath ) { @@ -32,4 +35,16 @@ const getAbsolutePathForApp = (relativePath, puter = globalThis.puter) => { return relativePath; }; -export default getAbsolutePathForApp; \ No newline at end of file +/** + * For request fields the backend reads as either a path or a uid: a + * UID-shaped string is sent as-is (a uid), anything else is resolved as a + * path. A relative name that looks like a UID needs a `./` prefix. + * + * @param {string} pathOrUid + * @param {unknown} [puter] + * @returns {string} + */ +export const getAbsolutePathOrUidForApp = (pathOrUid, puter = globalThis.puter) => + looksLikeUid(pathOrUid) ? pathOrUid : getAbsolutePathForApp(pathOrUid, puter); + +export default getAbsolutePathForApp; diff --git a/src/puter-js/tests/api/suites/fs.suite.ts b/src/puter-js/tests/api/suites/fs.suite.ts index 52a4cc3ff..6588105a5 100644 --- a/src/puter-js/tests/api/suites/fs.suite.ts +++ b/src/puter-js/tests/api/suites/fs.suite.ts @@ -348,6 +348,22 @@ export default suite('fs', { t.assert.deepEqual(rel, ['a', 'a/b', 'a/b/deep.txt', 'top.txt']); }, + 'readdir recursive with limit resolves to a page': async (t) => { + const base = `${home(t)}/fs-suite-recursive-limit`; + await t.puter.fs.write(`${base}/a/one.txt`, '1', { + createMissingParents: true, + }); + await t.puter.fs.write(`${base}/two.txt`, '2'); + const page = await t.puter.fs.readdir({ + path: base, + recursive: true, + limit: 1, + }); + t.assert.equal(Array.isArray(page), false); + t.assert.equal(page.items.length, 1); + t.assert.equal(typeof page.cursor, 'string'); + }, + 'copy duplicates a file': async (t) => { const src = `${home(t)}/fs-suite-copy-src.txt`; const dstDir = `${home(t)}/fs-suite-copy-dst`; @@ -426,6 +442,67 @@ export default suite('fs', { t.assert.equal(await blob.text(), 'move+rename'); }, + 'move into a directory addressed by uid': async (t) => { + const src = `${home(t)}/fs-suite-move-to-uid-src.txt`; + const dir = await t.puter.fs.mkdir( + `${home(t)}/fs-suite-move-to-uid-dst`, + ); + await t.puter.fs.write(src, 'into uid dir'); + await t.puter.fs.move(src, dir.uid); + const blob = await t.puter.fs.read( + `${home(t)}/fs-suite-move-to-uid-dst/fs-suite-move-to-uid-src.txt`, + ); + t.assert.equal(await blob.text(), 'into uid dir'); + }, + + 'read, copy, move and delete take a uid in place of a path': async (t) => { + const file = await t.puter.fs.write( + `${home(t)}/fs-suite-uid-selector.txt`, + 'by uid', + ); + const dir = await t.puter.fs.mkdir( + `${home(t)}/fs-suite-uid-selector-dir`, + ); + t.assert.equal( + await (await t.puter.fs.read(file.uid)).text(), + 'by uid', + ); + await t.puter.fs.copy(file.uid, dir.uid, { newName: 'copied.txt' }); + await t.puter.fs.move(file.uid, dir.uid, { newName: 'moved.txt' }); + const names = ( + await t.puter.fs.readdir(`${home(t)}/fs-suite-uid-selector-dir`) + ) + .map((e) => e.name) + .sort(); + t.assert.deepEqual(names, ['copied.txt', 'moved.txt']); + await t.puter.fs.delete(file.uid); + await t.assert.rejects( + () => t.puter.fs.stat({ uid: file.uid }), + 'deleted by uid', + ); + }, + + 'a UID-shaped relative name resolves as a path': async (t) => { + const dirName = '6a1f3c2e-8b4d-4e5f-9a6b-7c8d9e0f1a2b'; + const fileName = '7b2e4d3f-9c5e-4f60-8b7c-8d9e0f1a2b3c'; + const dir = await t.puter.fs.mkdir(dirName); + t.assert.equal(dir.path, `${home(t)}/${dirName}`); + await t.puter.fs.write(`${dirName}/inner.txt`, 'inner'); + const entries = await t.puter.fs.readdir(dirName); + t.assert.deepEqual( + entries.map((e) => e.name), + ['inner.txt'], + ); + t.assert.equal((await t.puter.fs.stat(dirName)).uid, dir.uid); + + await t.puter.fs.write(`${home(t)}/${fileName}`, 'uid-shaped name'); + t.assert.equal((await t.puter.fs.stat(fileName)).name, fileName); + // Methods that also take a uid read a bare UID-shaped string as one, + // so the relative name needs `./`. + const blob = await t.puter.fs.read(`./${fileName}`); + t.assert.equal(await blob.text(), 'uid-shaped name'); + }, + 'rename addresses the item by uid': async (t) => { const path = `${home(t)}/fs-suite-rename-uid.txt`; const written = await t.puter.fs.write(path, 'rename by uid'); @@ -1391,6 +1468,24 @@ export default suite('fs', { t.assert.equal(byUid.uid, written.uid); }, + 'eventual stat by uid returns the entry for that uid': async (t) => { + const a = await t.puter.fs.write( + `${home(t)}/fs-suite-stat-uid-a.txt`, + 'a', + ); + const b = await t.puter.fs.write( + `${home(t)}/fs-suite-stat-uid-b.txt`, + 'b', + ); + await t.puter.fs.stat({ uid: a.uid }); + const info = await t.puter.fs.stat({ + uid: b.uid, + consistency: 'eventual', + }); + t.assert.equal(info.uid, b.uid); + t.assert.equal(info.name, 'fs-suite-stat-uid-b.txt'); + }, + 'stat with eventual consistency serves the cached entry': async (t) => { const path = `${home(t)}/fs-suite-stat-cached.txt`; await t.puter.fs.write(path, 'cache me');