diff --git a/packages/teleport/src/DesktopSession/DesktopSession.tsx b/packages/teleport/src/DesktopSession/DesktopSession.tsx index 8897fcd2e..90ed8be6b 100644 --- a/packages/teleport/src/DesktopSession/DesktopSession.tsx +++ b/packages/teleport/src/DesktopSession/DesktopSession.tsx @@ -155,7 +155,7 @@ function Session(props: PropsWithChildren) { .showDirectoryPicker() .then(sharedDirHandle => { setIsSharingDirectory(true); - tdpClient.sharedDirectory = sharedDirHandle; + tdpClient.addSharedDirectory(sharedDirHandle); tdpClient.sendSharedDirectoryAnnounce(); }) .catch(() => { diff --git a/packages/teleport/src/lib/tdp/client.ts b/packages/teleport/src/lib/tdp/client.ts index 7fdd35eb7..19a32859c 100644 --- a/packages/teleport/src/lib/tdp/client.ts +++ b/packages/teleport/src/lib/tdp/client.ts @@ -25,8 +25,14 @@ import Codec, { ClientScreenSpec, PngFrame, ClipboardData, + FileType, SharedDirectoryErrCode, + SharedDirectoryInfoResponse, } from './codec'; +import { + PathDoesNotExistError, + SharedDirectoryManager, +} from './sharedDirectoryManager'; export enum TdpClientEvent { TDP_CLIENT_SCREEN_SPEC = 'tdp client screen spec', @@ -45,7 +51,7 @@ export default class Client extends EventEmitterWebAuthnSender { protected codec: Codec; protected socket: WebSocket | undefined; private socketAddr: string; - sharedDirectory: FileSystemDirectoryHandle | undefined; + private sdManager: SharedDirectoryManager; private logger = Logger.create('TDPClient'); @@ -53,6 +59,7 @@ export default class Client extends EventEmitterWebAuthnSender { super(); this.socketAddr = socketAddr; this.codec = new Codec(); + this.sdManager = new SharedDirectoryManager(); } // Connect to the websocket and register websocket event handlers. @@ -206,23 +213,51 @@ export default class Client extends EventEmitterWebAuthnSender { return; } - this.logger.info('Started sharing directory: ' + this.sharedDirectory.name); + this.logger.info('Started sharing directory: ' + this.sdManager.getName()); } - handleSharedDirectoryInfoRequest(buffer: ArrayBuffer) { + async handleSharedDirectoryInfoRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryInfoRequest(buffer); - // TODO(isaiah): remove debug once message is handled. - this.logger.debug( - 'Received SharedDirectoryInfoRequest: ' + JSON.stringify(req) - ); - // TODO(isaiah): here's where we'll respond with SharedDirectoryInfoResponse + const path = req.path; + try { + const info = await this.sdManager.getInfo(path); + this.sendSharedDirectoryInfoResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + fso: { + lastModified: BigInt(info.lastModified), + fileType: info.kind === 'file' ? FileType.File : FileType.Directory, + size: BigInt(info.size), + path: path, + }, + }); + } catch (e) { + if (e.constructor === PathDoesNotExistError) { + this.sendSharedDirectoryInfoResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.DoesNotExist, + fso: { + lastModified: BigInt(0), + fileType: FileType.File, + size: BigInt(0), + path: path, + }, + }); + } else { + this.handleError(e); + } + } } protected send( data: string | ArrayBufferLike | Blob | ArrayBufferView ): void { if (this.socket && this.socket.readyState === 1) { - this.socket.send(data); + try { + this.socket.send(data); + } catch (e) { + this.handleError(e); + } return; } @@ -263,32 +298,30 @@ export default class Client extends EventEmitterWebAuthnSender { this.send(msg); } - private sharedDirectoryReady() { - if (!this.sharedDirectory) { - this.handleError( - new Error( - 'attempted to use a shared directory before one was initialized' - ) - ); - return false; + addSharedDirectory(sharedDirectory: FileSystemDirectoryHandle) { + try { + this.sdManager.add(sharedDirectory); + } catch (err) { + this.handleError(err); } - - return true; } sendSharedDirectoryAnnounce() { - if (!this.sharedDirectoryReady()) return; - this.socket.send( + this.send( this.codec.encodeSharedDirectoryAnnounce({ completionId: 0, // This is always the first request. // Hardcode directoryId for now since we only support sharing 1 directory. // We're using 2 because the smartcard device is hardcoded to 1 in the backend. directoryId: 2, - name: this.sharedDirectory.name, + name: this.sdManager.getName(), }) ); } + sendSharedDirectoryInfoResponse(res: SharedDirectoryInfoResponse) { + this.send(this.codec.encodeSharedDirectoryInfoResponse(res)); + } + resize(spec: ClientScreenSpec) { this.send(this.codec.encodeClientScreenSpec(spec)); } diff --git a/packages/teleport/src/lib/tdp/codec.ts b/packages/teleport/src/lib/tdp/codec.ts index ccc1e848d..d5f993a08 100644 --- a/packages/teleport/src/lib/tdp/codec.ts +++ b/packages/teleport/src/lib/tdp/codec.ts @@ -40,6 +40,7 @@ export enum MessageType { SHARED_DIRECTORY_ANNOUNCE = 11, SHARED_DIRECTORY_ACKNOWLEDGE = 12, SHARED_DIRECTORY_INFO_REQUEST = 13, + SHARED_DIRECTORY_INFO_RESPONSE = 14, __LAST, // utility value } @@ -95,7 +96,7 @@ export type SharedDirectoryAnnounce = { name: string; }; -// | message type (12) | errCode error | directory_id uint32 | +// | message type (12) | err_code error | directory_id uint32 | export type SharedDirectoryAcknowledge = { errCode: SharedDirectoryErrCode; directoryId: number; @@ -108,6 +109,21 @@ export type SharedDirectoryInfoRequest = { path: string; }; +// | message type (14) | completion_id uint32 | err_code uint32 | file_system_object fso | +export type SharedDirectoryInfoResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; + fso: FileSystemObject; +}; + +// | last_modified uint64 | size uint64 | file_type uint32 | path_length uint32 | path byte[] | +export type FileSystemObject = { + lastModified: bigint; + size: bigint; + fileType: FileType; + path: string; +}; + export enum SharedDirectoryErrCode { // nil (no error, operation succeeded) Nil = 0, @@ -119,6 +135,11 @@ export enum SharedDirectoryErrCode { AlreadyExists = 3, } +export enum FileType { + File = 0, + Directory = 1, +} + function toSharedDirectoryErrCode(errCode: number): SharedDirectoryErrCode { if (!(errCode in SharedDirectoryErrCode)) { throw new Error(`attempted to convert invalid error code ${errCode}`); @@ -457,6 +478,51 @@ export default class Codec { return buffer; } + // | message type (14) | completion_id uint32 | err_code uint32 | file_system_object fso | + encodeSharedDirectoryInfoResponse(res: SharedDirectoryInfoResponse): Message { + const bufLenSansFso = byteLength + 2 * uint32Length; + const bufferSansFso = new ArrayBuffer(bufLenSansFso); + const view = new DataView(bufferSansFso); + let offset = 0; + + view.setUint8(offset++, MessageType.SHARED_DIRECTORY_INFO_RESPONSE); + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + + const fsoBuffer = this.encodeFileSystemObject(res.fso); + + // https://gist.github.com/72lions/4528834?permalink_comment_id=2395442#gistcomment-2395442 + return new Uint8Array([ + ...new Uint8Array(bufferSansFso), + ...new Uint8Array(fsoBuffer), + ]).buffer; + } + + // | last_modified uint64 | size uint64 | file_type uint32 | path_length uint32 | path byte[] | + encodeFileSystemObject(fso: FileSystemObject): Message { + const dataUtf8array = this.encoder.encode(fso.path); + + const bufLen = 2 * uint64Length + 2 * uint32Length + dataUtf8array.length; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + view.setBigUint64(offset, fso.lastModified); + offset += uint64Length; + view.setBigUint64(offset, fso.size); + offset += uint64Length; + view.setUint32(offset, fso.fileType); + offset += uint32Length; + view.setUint32(offset, dataUtf8array.length); + offset += uint32Length; + dataUtf8array.forEach(byte => { + view.setUint8(offset++, byte); + }); + + return buffer; + } + // decodeClipboardData decodes clipboard data decodeClipboardData(buffer: ArrayBuffer): ClipboardData { return { @@ -533,7 +599,7 @@ export default class Codec { return pngFrame; } - // | message type (12) | errCode error | directory_id uint32 | + // | message type (12) | err_code error | directory_id uint32 | decodeSharedDirectoryAcknowledge( buffer: ArrayBuffer ): SharedDirectoryAcknowledge { @@ -541,7 +607,7 @@ export default class Codec { let offset = 0; offset += byteLength; // eat message type const errCode = toSharedDirectoryErrCode(dv.getUint32(offset)); - offset += uint32Length; // eat errCode + offset += uint32Length; // eat err_code const directoryId = dv.getUint32(5); return { @@ -579,3 +645,4 @@ export default class Codec { const byteLength = 1; const uint32Length = 4; +const uint64Length = uint32Length * 2; diff --git a/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts b/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts new file mode 100644 index 000000000..96eda0432 --- /dev/null +++ b/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts @@ -0,0 +1,119 @@ +// Copyright 2022 Gravitational, Inc. +// +// 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. + +// SharedDirectoryManager manages a FileSystemDirectoryHandle for use +// by the TDP client. Most of its methods can potentially throw errors +// and so should be wrapped in try/catch blocks. +export class SharedDirectoryManager { + private dir: FileSystemDirectoryHandle | undefined; + + add(sharedDirectory: FileSystemDirectoryHandle) { + if (this.dir) { + throw new Error( + 'SharedDirectoryManager currently only supports sharing a single directory' + ); + } + this.dir = sharedDirectory; + } + + getName(): string { + this.checkReady(); + return this.dir.name; + } + + // Gets the information for the file or directory + // at path where path is the relative path from the + // root directory. + async getInfo(path: string): Promise<{ + size: number; // bytes + lastModified: number; // ms since unix epoch + kind: 'file' | 'directory'; + }> { + this.checkReady(); + + const fileOrDir = await this.walkPath(path); + + if (fileOrDir.kind === 'directory') { + // Magic numbers are the values for directories where the true + // value is unavailable, according to the TDP spec. + return { size: 4096, lastModified: 0, kind: fileOrDir.kind }; + } + + let file = await fileOrDir.getFile(); + return { + size: file.size, + lastModified: file.lastModified, + kind: fileOrDir.kind, + }; + } + + // walkPath walks a pathstr (assumed to be in the qualified Unix format specified + // in the TDP spec), returning the FileSystemDirectoryHandle | FileSystemFileHandle + // it finds at its end. If the pathstr isn't a valid path in the shared directory, + // it throws an error. + private async walkPath( + pathstr: string + ): Promise { + if (pathstr === '') { + return this.dir; + } + + let path = pathstr.split('/'); + + let walkIt = async ( + dir: FileSystemDirectoryHandle, + path: string[] + ): Promise => { + // Pop the next path element off the stack + let nextPathElem = path.shift(); + + // Iterate through the items in the directory + for await (const entry of dir.values()) { + // If we find the entry we're looking for + if (entry.name === nextPathElem) { + if (path.length === 0) { + // We're at the end of the path, so this + // is the end element we've been walking towards. + return entry; + } else if (entry.kind === 'directory') { + // We're not at the end of the path and + // have encountered a directory, recurse + // further. + return walkIt(entry, path); + } else { + break; + } + } + } + + throw new PathDoesNotExistError('path does not exist'); + }; + + return walkIt(this.dir, path); + } + + private checkReady() { + if (!this.dir) { + throw new Error( + 'attempted to use a shared directory before one was initialized' + ); + } + } +} + +export class PathDoesNotExistError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/yarn.lock b/yarn.lock index 22e48963b..0d5599b7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3091,10 +3091,10 @@ tapable "^2.2.0" webpack "^5" -"@types/wicg-native-file-system@^2020.6.0": - version "2020.6.0" - resolved "https://registry.yarnpkg.com/@types/wicg-native-file-system/-/wicg-native-file-system-2020.6.0.tgz#63cbb7bac47bdb9eae4b0d66e63134b33e47e05d" - integrity sha512-M7n6jvHfUzUXDtf6UGpL6rVIddV7UzEYrvwZPORApeHvDGQnZJ79fXorLlDj8xJKyUemnEBohRd8yx09k9NBUw== +"@types/wicg-file-system-access@^2020.9.5": + version "2020.9.5" + resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz#4a0c8f3d1ed101525f329e86c978f7735404474f" + integrity sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA== "@types/ws@^8.5.1": version "8.5.3" @@ -10412,6 +10412,22 @@ node-gyp@8.4.1: tar "^6.1.2" which "^2.0.2" +node-gyp@8.4.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937" + integrity sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.6" + make-fetch-happen "^9.1.0" + nopt "^5.0.0" + npmlog "^6.0.0" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.2" + which "^2.0.2" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"