From 0591f13cc2281bfc3910e71ec952639fe5b63895 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 25 Aug 2026 17:44:50 -0400 Subject: [PATCH] Expose is_shared and returnShares in puter.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the flag into the v1 entry shape and add returnShares to stat(), whose shares are mapped into the same Share objects getShares() returns. returnShares joins the dedupe key and bypasses the entry cache in both directions — a result carrying share data is never written to it, so a later plain stat cannot serve other people's names from cache. --- src/puter-js/index.d.ts | 1 + .../FileSystem/operations/operations.test.js | 46 +++++++++++++++++++ .../src/modules/FileSystem/operations/stat.js | 19 +++++++- src/puter-js/src/modules/FileSystem/types.js | 8 ++++ .../FileSystem/utils/mapV2EntryToV1.js | 1 + .../tests/api/suites/sharing.suite.ts | 31 +++++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index dc1264bd6..879e39fa8 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -96,6 +96,7 @@ export type { export type { CopyOptions, DeleteOptions, + FSItemWithShares, GetSharesOptions, ListSharedOptions, MkdirOptions, 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 a9cf968fa..bc41d3d28 100644 --- a/src/puter-js/src/modules/FileSystem/operations/operations.test.js +++ b/src/puter-js/src/modules/FileSystem/operations/operations.test.js @@ -382,6 +382,43 @@ describe('stat', () => { await fs.stat({ path: '/a/file.txt', consistency: 'eventual' }); expect(FakeXHR.requests).toHaveLength(0); }); + + it('asks for shares and publishes them in the SDK shape', async () => { + FakeXHR.respondWith = () => ({ + uid: 'u1', + is_dir: false, + is_shared: true, + shares: [ + { + uid: 's1', + mode: 'write', + uid_entry: 'u1', + is_dir: false, + holder: 'someone', + inherited_from: null, + }, + ], + }); + const item = await fs.stat('/a/file.txt', { returnShares: true }); + expect(lastBody()).toMatchObject({ return_shares: true }); + expect(item.is_shared).toBe(true); + expect(item.shares[0]).toMatchObject({ + uid: 's1', + mode: 'write', + entryUid: 'u1', + holder: 'someone', + inheritedFrom: null, + }); + }); + + 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 }); + FakeXHR.requests = []; + // Would be served from the cache had the previous call populated it. + await fs.stat({ path: '/a/cached.txt', consistency: 'eventual' }); + expect(FakeXHR.requests).toHaveLength(1); + }); }); describe('readdir', () => { @@ -396,6 +433,15 @@ describe('readdir', () => { expect(entries[0]).toMatchObject({ name: 'file.txt', is_dir: false, uid: 'u1' }); }); + it('carries the share flag into the v1 shape', async () => { + FakeXHR.respondWith = () => [ + { uuid: 'u1', name: 'mine.txt', path: '/a/mine.txt', isShared: true }, + { uuid: 'u2', name: 'theirs.txt', path: '/a/theirs.txt', isShared: null }, + ]; + const entries = await fs.readdir('/a'); + expect(entries.map((e) => e.is_shared)).toEqual([true, null]); + }); + it('readdir(path, options) applies the options', async () => { await fs.readdir('/a', { limit: 5, sortBy: 'modified', sortOrder: 'desc' }); expect(lastBody()).toMatchObject({ limit: 5, sortBy: 'modified', sortOrder: 'desc' }); diff --git a/src/puter-js/src/modules/FileSystem/operations/stat.js b/src/puter-js/src/modules/FileSystem/operations/stat.js index 2446a555e..bc275dc94 100644 --- a/src/puter-js/src/modules/FileSystem/operations/stat.js +++ b/src/puter-js/src/modules/FileSystem/operations/stat.js @@ -1,15 +1,24 @@ import { dedupe } from '../../../lib/networkUtils.js'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { fsRequest, parseOperationArgs } from './scaffold.js'; +import { toShare } from './shareUtil.js'; /** @typedef {import('../types.js').StatOptions} StatOptions */ +/** @typedef {import('../types.js').FSItemWithShares} FSItemWithShares */ /** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * @typedef {{ + * (options: StatOptions & { returnShares: true }): Promise, * (options: StatOptions): Promise, * ( * path: string, + * options: StatOptions & { returnShares: true }, + * success?: (value: FSItemWithShares) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * ( + * path: string, * options?: StatOptions, * success?: (value: FSItem) => void, * error?: (reason: unknown) => void, @@ -50,7 +59,7 @@ const statImpl = async function (...args) { cacheKey = `item:${ options.path}`; } - if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize ) { + if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) { const cachedResult = await puter._cache.get(cacheKey); if ( cachedResult ) { return cachedResult; @@ -65,6 +74,7 @@ const statImpl = async function (...args) { returnPermissions: options.returnPermissions, returnVersions: options.returnVersions, returnSize: options.returnSize, + returnShares: options.returnShares, consistency: options.consistency, }); @@ -80,6 +90,7 @@ const statImpl = async function (...args) { body.return_permissions = options.returnPermissions; body.return_versions = options.returnVersions; body.return_size = options.returnSize; + body.return_shares = options.returnShares; body.auth_token = this.authToken; return fsRequest.call(this, { @@ -90,7 +101,11 @@ const statImpl = async function (...args) { success: options.success, error: options.error, transform: (result) => { - if ( JSON.stringify(result).length <= MAX_CACHE_SIZE ) { + if ( Array.isArray(result?.shares) ) { + 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 ) { puter._cache.set(cacheKey, result); } return result; diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index b6642f831..75363bf1f 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -164,6 +164,8 @@ * @property {boolean} [returnPermissions] Whether to return permission information. Defaults to `false`. * @property {boolean} [returnVersions] Whether to return version information. Defaults to `false`. * @property {boolean} [returnSize] Whether to return size information. Defaults to `false`. + * @property {boolean} [returnShares] Whether to return who the item is shared with, as a `shares` + * array on the result. Empty unless you can manage the item. Defaults to `false`. */ /** @@ -172,6 +174,12 @@ * @typedef {StatOptionsOwn & RequestCallbacks} StatOptions */ +/** + * A `stat()` result with `returnShares` set. + * + * @typedef {FSItem & { shares: Share[] }} FSItemWithShares + */ + /** * @typedef {Object} UploadOptionsOwn * @property {boolean} [overwrite] Whether to overwrite the destination file if it already exists. diff --git a/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js b/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js index 99ef2a482..614495787 100644 --- a/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js +++ b/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js @@ -39,6 +39,7 @@ const mapV2EntryToV1 = (entry) => { type: entry.type ?? null, writable: true, is_public: entry.isPublic ?? null, + is_shared: entry.isShared ?? null, thumbnail: entry.thumbnail ?? null, immutable: Boolean(entry.immutable), metadata: entry.metadata ?? null, diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts index 72ba615f8..9b14a91db 100644 --- a/src/puter-js/tests/api/suites/sharing.suite.ts +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -79,6 +79,37 @@ export default suite('sharing', { t.assert.equal(shares[0].issuer, t.env.users.user.username); }, + 'stat and readdir report whether an item is shared': async (t) => { + // A directory of its own, so the listing is just these two entries. + const dir = scratch(t, 'flag').replace(/\.txt$/, ''); + const shared = `${dir}/shared.txt`; + const sibling = `${dir}/sibling.txt`; + await t.puter.fs.write(shared, 'x', { createMissingParents: true }); + await t.puter.fs.write(sibling, 'x'); + await t.puter.fs.share(shared, t.env.users.other.username, 'write'); + + const listing = await t.puter.fs.readdir(dir); + const flags = Object.fromEntries( + listing.map((item) => [item.name, item.is_shared]), + ); + t.assert.equal(flags['shared.txt'], true); + t.assert.equal(flags['sibling.txt'], false); + + const item = await t.puter.fs.stat(shared, { returnShares: true }); + t.assert.equal(item.is_shared, true); + t.assert.equal(item.shares.length, 1); + t.assert.equal(item.shares[0].holder, t.env.users.other.username); + t.assert.equal(item.shares[0].mode, 'write'); + // Mapped into the same shape `getShares()` publishes. + t.assert.equal(item.shares[0].entryUid, item.uid); + t.assert.equal(item.shares[0].inheritedFrom, null); + + await t.puter.fs.unshare(shared, t.env.users.other.username); + const after = await t.puter.fs.stat(shared); + t.assert.equal(after.is_shared, false); + t.assert.equal(after.shares, undefined); + }, + 'changing the mode replaces the share rather than adding one': async (t) => { const path = scratch(t, 'remode'); await t.puter.fs.write(path, 'x');