diff --git a/.gitignore b/.gitignore index 829b328..3fefd17 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ tsconfig.tsbuildinfo img.png +*.log + # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output diff --git a/packages/common/index.ts b/packages/common/index.ts index a0ffbd6..b8c15e2 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1,12 +1,11 @@ -import { File } from "@prisma/client"; +import { File, Link } from "@prisma/client"; type FileType = "file" | "dir" | "symlink"; -type Result = - | { +type Result = + | ({ status: "ok"; - file: T; - } + } & { [P in K]: T }) | { status: "not_found"; }; @@ -14,7 +13,9 @@ type Result = // TODO: bump this based on the latest state of the actual backend! export interface Backend { - getFiles: (dir: string) => Promise; + // TODO: use resule + getLinks: (dir: string) => Promise; + getLink: (dir: string) => Promise>; getFile: (filepath: string) => Promise>; createFile: ( @@ -23,16 +24,13 @@ export interface Backend { mode: number, uid: number, gid: number, - targetId: number - ) => Promise>; - - writeFile: ( - filepath: string, - uid: number, - gid: number + targetPath: string ) => Promise>; - deleteFile: (filepath: string) => Promise>; - renameFile: (srcPath: string, destPath: string) => Promise>; + deleteFile: (filepath: string) => Promise>; + renameFile: ( + srcPath: string, + destPath: string + ) => Promise>; updateMode: (filepath: string, mode: number) => Promise>; } diff --git a/packages/fuse-client/syscalls/getattr.ts b/packages/fuse-client/syscalls/getattr.ts index 269d1f3..aa760fc 100644 --- a/packages/fuse-client/syscalls/getattr.ts +++ b/packages/fuse-client/syscalls/getattr.ts @@ -6,9 +6,8 @@ export const getattr: (backend: SQLiteBackend) => MountOptions["getattr"] = ( backend ) => { return async (path, cb) => { - console.log("getattr(%s)", path); + console.info("getattr(%s)", path); const r = await backend.getFile(path); - await match(r) .with({ status: "ok" }, async (r) => { const rSize = await backend.getFileSize(path); @@ -16,13 +15,15 @@ export const getattr: (backend: SQLiteBackend) => MountOptions["getattr"] = ( cb(fuse.ENOENT); return; } - + const rNlinks = await backend.getFileNLinks(path); const { mtime, atime, ctime, mode } = r.file; cb(0, { mtime, atime, ctime, - nlink: 1, + blocks: 1, + ino: r.file.id, + nlink: rNlinks.nLinks?.length || 1, size: rSize.size, mode: mode, // TODO: enable posix mode where real uid/gid are returned diff --git a/packages/fuse-client/syscalls/init.ts b/packages/fuse-client/syscalls/init.ts index 94fd556..2c6d8e4 100644 --- a/packages/fuse-client/syscalls/init.ts +++ b/packages/fuse-client/syscalls/init.ts @@ -6,7 +6,7 @@ export const init: (backend: SQLiteBackend) => MountOptions["init"] = ( backend ) => { return async (cb) => { - console.log("init"); + console.info("init"); //@ts-expect-error fix types const context = fuse.context(); diff --git a/packages/fuse-client/syscalls/link.ts b/packages/fuse-client/syscalls/link.ts index 73294d2..6b75985 100644 --- a/packages/fuse-client/syscalls/link.ts +++ b/packages/fuse-client/syscalls/link.ts @@ -1,14 +1,25 @@ import { SQLiteBackend } from "@zoid-fs/sqlite-backend"; -import { MountOptions } from "@zoid-fs/node-fuse-bindings"; -import { symlink } from "./symlink"; +import fuse, { MountOptions } from "@zoid-fs/node-fuse-bindings"; +import { match } from "ts-pattern"; export const link: (backend: SQLiteBackend) => MountOptions["link"] = ( backend ) => { - return async (src, dest, cb) => { - console.log("link(%s, %s)", src, dest); - //@ts-expect-error fix types - // TODO: implement link properly - symlink(backend)(src, dest, cb); + return async (srcPath, destPath, cb) => { + console.info("link(%s, %s)", srcPath, destPath); + + // TODO: throw if destination doesn't exist + + // TODO: double check if mode for link is correct + // https://unix.stackexchange.com/questions/193465/what-file-mode-is-a-link + const r = await backend.createLink(srcPath, destPath); + match(r) + .with({ status: "ok" }, () => { + cb(0); + }) + .with({ status: "not_found" }, () => { + cb(fuse.ENOENT); + }) + .exhaustive(); }; }; diff --git a/packages/fuse-client/syscalls/open.ts b/packages/fuse-client/syscalls/open.ts index a921e86..d8965a1 100644 --- a/packages/fuse-client/syscalls/open.ts +++ b/packages/fuse-client/syscalls/open.ts @@ -6,7 +6,7 @@ export const open: (backend: SQLiteBackend) => MountOptions["open"] = ( backend ) => { return async (path, flags, cb) => { - console.log("open(%s, %d)", path, flags); + console.info("open(%s, %d)", path, flags); const r = await backend.getFile(path); match(r) diff --git a/packages/fuse-client/syscalls/opendir.ts b/packages/fuse-client/syscalls/opendir.ts index 78de909..367ad57 100644 --- a/packages/fuse-client/syscalls/opendir.ts +++ b/packages/fuse-client/syscalls/opendir.ts @@ -6,7 +6,7 @@ export const opendir: (backend: SQLiteBackend) => MountOptions["opendir"] = ( backend ) => { return async (path, flags, cb) => { - console.log("opendir(%s, %d)", path, flags); + console.info("opendir(%s, %d)", path, flags); if (path === "/") { cb(0, 42); // TODO: Universal FD for root dir, it should probably be in the database as bootstrap diff --git a/packages/fuse-client/syscalls/readdir.ts b/packages/fuse-client/syscalls/readdir.ts index f840c4c..42770e2 100644 --- a/packages/fuse-client/syscalls/readdir.ts +++ b/packages/fuse-client/syscalls/readdir.ts @@ -10,8 +10,8 @@ export const readdir: (backend: SQLiteBackend) => MountOptions["readdir"] = ( // TODO: figure out how are these directories in output of ls -la const dotDirs = [".", ".."]; - const files = await backend.getFiles(path); - const fileNames = dotDirs.concat(files.map((file) => file.name)); + const links = await backend.getLinks(path); + const fileNames = dotDirs.concat(links.map((link) => link.name)); return cb(0, fileNames); }; diff --git a/packages/fuse-client/syscalls/readlink.ts b/packages/fuse-client/syscalls/readlink.ts index fe32b72..823ecc2 100644 --- a/packages/fuse-client/syscalls/readlink.ts +++ b/packages/fuse-client/syscalls/readlink.ts @@ -6,16 +6,23 @@ export const readlink: (backend: SQLiteBackend) => MountOptions["readlink"] = ( backend ) => { return async (path, cb) => { - console.log("readlink(%s)", path); - const r = await backend.getFile(path); - match(r) - .with({ status: "ok" }, (r) => { - cb(0, r.file.name); - }) - .with({ status: "not_found" }, () => { - //@ts-expect-error fix types, what to do if readlink fails? - cb(fuse.ENOENT); - }) - .exhaustive(); + console.info("readlink(%s)", path); + try { + const r = await backend.getLink(path); + match(r) + .with({ status: "ok" }, (r) => { + cb(0, r.link.targetPath); + }) + .with({ status: "not_found" }, () => { + //@ts-expect-error fix types, what to do if readlink fails? + cb(fuse.ENOENT); + }) + .exhaustive(); + } catch (e) { + console.error(e); + return { + status: "not_found" as const, + }; + } }; }; diff --git a/packages/fuse-client/syscalls/symlink.ts b/packages/fuse-client/syscalls/symlink.ts index 7400cda..ab80bc1 100644 --- a/packages/fuse-client/syscalls/symlink.ts +++ b/packages/fuse-client/syscalls/symlink.ts @@ -1,17 +1,25 @@ import { SQLiteBackend } from "@zoid-fs/sqlite-backend"; import fuse, { MountOptions } from "@zoid-fs/node-fuse-bindings"; import { match } from "ts-pattern"; +import { constants } from "fs"; +import path from "path"; export const symlink: (backend: SQLiteBackend) => MountOptions["symlink"] = ( backend ) => { return async (srcPath, destPath, cb) => { - console.log("symlink(%s, %s)", srcPath, destPath); + console.info("symlink(%s, %s)", srcPath, destPath); - const targetFile = await backend.getFile(srcPath); - console.log({ targetFile }); - if (targetFile.status === "not_found") { - cb(fuse.ENOENT); + const parsedDestPath = path.parse(destPath); + // Note: actually 255 as per the spec but we get an extra / in the dest path + if (parsedDestPath.base.length > 255) { + cb(fuse.ENAMETOOLONG); + return; + } + + // Note: actually 1023 as per the spec but we get an extra / in the dest path + if (srcPath.length > 1023 || destPath.length > 1023) { + cb(fuse.ENAMETOOLONG); return; } @@ -24,10 +32,10 @@ export const symlink: (backend: SQLiteBackend) => MountOptions["symlink"] = ( const r = await backend.createFile( destPath, "symlink", - 33188, + constants.S_IFLNK, uid, gid, - targetFile.file.id + srcPath ); match(r) .with({ status: "ok" }, () => { diff --git a/packages/sqlite-backend/SQLiteBackend.ts b/packages/sqlite-backend/SQLiteBackend.ts index 02a1407..3338d43 100644 --- a/packages/sqlite-backend/SQLiteBackend.ts +++ b/packages/sqlite-backend/SQLiteBackend.ts @@ -12,7 +12,7 @@ export type ContentChunk = { size: number; }; -const WRITE_BUFFER_SIZE = 10; +const WRITE_BUFFER_SIZE = 1000; export class SQLiteBackend implements Backend { private readonly writeBuffers: Map> = @@ -57,42 +57,63 @@ export class SQLiteBackend implements Backend { await writeBuffer?.flush(); } - async getFiles(dir: string) { - const files = await this.prisma.file.findMany({ + async getLinks(dir: string) { + const files = await this.prisma.link.findMany({ where: { - dir, + AND: [ + { + dir, + }, + { + path: { + not: "/", + }, + }, + ], }, }); return files; } - async getFile(filepath: string) { + async getLink(filepath: string) { try { - const fileOrSymlink = await this.prisma.file.findFirstOrThrow({ + const link = await this.prisma.link.findFirstOrThrow({ where: { path: filepath, }, }); - const file = await match(fileOrSymlink.type === "symlink") - .with(true, async () => { - const targetFile = await this.prisma.file.findFirstOrThrow({ - where: { - id: fileOrSymlink.targetId, - }, - }); - return { - ...targetFile, - mode: constants.S_IFLNK, - }; - }) - .otherwise(() => fileOrSymlink); + return { + status: "ok" as const, + link, + }; + } catch (e) { + console.error(e); + return { + status: "not_found" as const, + }; + } + } + async getFile(filepath: string) { + try { + const link = await this.prisma.link.findFirstOrThrow({ + where: { + path: filepath, + }, + }); + + const file = await this.prisma.file.findFirstOrThrow({ + where: { + id: link.fileId, + }, + }); return { status: "ok" as const, - file: file, + file, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -127,6 +148,7 @@ export class SQLiteBackend implements Backend { chunks, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -135,11 +157,11 @@ export class SQLiteBackend implements Backend { async getFileSize(filepath: string) { try { + const file = await this.getFile(filepath); + // TODO: error handling const chunks = await this.prisma.content.findMany({ where: { - file: { - path: filepath, - }, + fileId: file.file?.id, }, }); const bufChunk = Buffer.concat(chunks.map((chunk) => chunk.content)); @@ -148,74 +170,106 @@ export class SQLiteBackend implements Backend { size: Buffer.byteLength(bufChunk), }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; } } - async createFile( - filepath: string, - type = "file", - mode = 16877, // dir (for default to be file, use 33188) - uid: number, - gid: number, - targetId: number = 0 - ) { + async getFileNLinks(filepath: string) { try { - const parsedPath = path.parse(filepath); - const file = await this.prisma.file.create({ + const file = await this.getFile(filepath); + + // TODO: error handling + const nLinks = await this.prisma.link.findMany({ + where: { + fileId: file.file?.id, + }, + }); + return { + status: "ok" as const, + nLinks, + }; + } catch (e) { + console.error(e); + return { + status: "not_found" as const, + }; + } + } + + async createLink(filepath: string, destinationPath: string) { + try { + // Note: destination is new link, filepath is the existing file + const file = await this.getFile(filepath); + const parsedPath = path.parse(destinationPath); + + const link = await this.prisma.link.create({ data: { name: parsedPath.base, dir: parsedPath.dir, - path: filepath, - type, - mode: type === "dir" ? 16877 : mode, - atime: new Date(), - mtime: new Date(), - ctime: new Date(), - uid, - gid, - targetId, + path: destinationPath, + type: "file", // Note: hard link is a regular file + fileId: file.file!.id, }, }); + return { status: "ok" as const, - file: file, + file: link, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; } } - async writeFile(filepath: string, uid: number, gid: number) { + async createFile( + filepath: string, + type = "file", + mode = 16877, // dir (for default to be file, use 33188) + uid: number, + gid: number, + targetPath: string = "" + ) { try { const parsedPath = path.parse(filepath); - const file = await this.prisma.file.upsert({ - where: { - path: filepath, - }, - update: {}, - create: { - type: "file", - name: parsedPath.base, - dir: parsedPath.dir, - path: filepath, - mode: 755, - atime: new Date(), - mtime: new Date(), - ctime: new Date(), - uid, - gid, - }, + + const { file, link } = await this.prisma.$transaction(async (tx) => { + const file = await tx.file.create({ + data: { + mode: type === "dir" ? 16877 : mode, + atime: new Date(), + mtime: new Date(), + ctime: new Date(), + uid, + gid, + }, + }); + + const link = await tx.link.create({ + data: { + name: parsedPath.base, + type, + dir: parsedPath.dir, + path: filepath, + targetPath, + fileId: file.id, + }, + }); + + return { file, link }; }); + return { status: "ok" as const, file: file, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -270,6 +324,7 @@ export class SQLiteBackend implements Backend { chunks, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -278,11 +333,14 @@ export class SQLiteBackend implements Backend { async truncateFile(filepath: string, size: number) { try { + const link = await this.prisma.link.findFirstOrThrow({ + where: { + path: filepath, + }, + }); const file = await this.prisma.content.deleteMany({ where: { - file: { - path: filepath, - }, + fileId: link.fileId, offset: { gte: size, }, @@ -293,6 +351,7 @@ export class SQLiteBackend implements Backend { file: file, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -301,17 +360,23 @@ export class SQLiteBackend implements Backend { async deleteFile(filepath: string) { try { - const file = await this.prisma.file.delete({ + const link = await this.prisma.link.findFirstOrThrow({ where: { path: filepath, }, }); + const file = await this.prisma.file.deleteMany({ + where: { + id: link.fileId, + }, + }); return { status: "ok" as const, - file: file, + count: file.count, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -323,28 +388,42 @@ export class SQLiteBackend implements Backend { const parsedSrcPath = path.parse(srcPath); const parsedDestPath = path.parse(destPath); - // Note: Delete if the destiantion path already exists - await this.prisma.file.deleteMany({ - where: { - path: destPath, - }, - }); + const { updatedLink: link } = await this.prisma.$transaction( + async (tx) => { + // Note: Delete if the destiantion path already exists + const link = await tx.link.findFirstOrThrow({ + where: { + path: destPath, + }, + }); + + // Note: deleting file should delete link and content as cascade delete is enabled + const fileDeleteMany = await tx.file.deleteMany({ + where: { + id: link.fileId, + }, + }); + + const updatedLink = await tx.link.update({ + where: { + name: parsedSrcPath.base, + dir: parsedSrcPath.dir, + path: srcPath, + }, + data: { + name: parsedDestPath.base, + dir: parsedDestPath.dir, + path: destPath, + }, + }); + + return { updatedLink }; + } + ); - const file = await this.prisma.file.update({ - where: { - name: parsedSrcPath.base, - dir: parsedSrcPath.dir, - path: srcPath, - }, - data: { - name: parsedDestPath.base, - dir: parsedDestPath.dir, - path: destPath, - }, - }); return { status: "ok" as const, - file: file, + link, }; } catch (e) { console.error(e); @@ -357,19 +436,29 @@ export class SQLiteBackend implements Backend { async updateMode(filepath: string, mode: number) { try { - const file = await this.prisma.file.update({ - where: { - path: filepath, - }, - data: { - mode, - }, + const { file, link } = await this.prisma.$transaction(async (tx) => { + const link = await tx.link.findFirstOrThrow({ + where: { + path: filepath, + }, + }); + const file = await tx.file.update({ + where: { + id: link.fileId, + }, + data: { + mode, + }, + }); + return { file, link }; }); + return { status: "ok" as const, file: file, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; @@ -378,20 +467,30 @@ export class SQLiteBackend implements Backend { async updateTimes(filepath: string, atime: number, mtime: number) { try { - const file = await this.prisma.file.update({ - where: { - path: filepath, - }, - data: { - atime: new Date(atime), - mtime: new Date(mtime), - }, + const { file, link } = await this.prisma.$transaction(async (tx) => { + const link = await tx.link.findFirstOrThrow({ + where: { + path: filepath, + }, + }); + const file = await tx.file.update({ + where: { + id: link.fileId, + }, + data: { + atime: new Date(atime), + mtime: new Date(mtime), + }, + }); + return { file, link }; }); + return { status: "ok" as const, file: file, }; } catch (e) { + console.error(e); return { status: "not_found" as const, }; diff --git a/packages/sqlite-backend/prisma/schema.prisma b/packages/sqlite-backend/prisma/schema.prisma index d6c03b2..b064968 100644 --- a/packages/sqlite-backend/prisma/schema.prisma +++ b/packages/sqlite-backend/prisma/schema.prisma @@ -13,20 +13,27 @@ datasource db { } model File { - id Int @id @default(autoincrement()) - type String // dir or file or symlink - targetId Int @default(0) // only relevant for symlink otherwise 0 - mode Int - name String - dir String - path String @unique + id Int @id @default(autoincrement()) + mode Int // fileType String // text or binary - uid Int - gid Int - atime DateTime - mtime DateTime @updatedAt - ctime DateTime @default(now()) - Content Content[] + uid Int + gid Int + atime DateTime + mtime DateTime @updatedAt + ctime DateTime @default(now()) + Content Content[] + Link Link[] +} + +model Link { + id Int @id @default(autoincrement()) + name String + type String // dir or file or symlink + dir String + path String @unique + targetPath String @default("") // Only relevant for symlink, target path + file File @relation(fields: [fileId], references: [id], onDelete: Cascade) + fileId Int } model Content { diff --git a/packages/zoid-fs-client/package.json b/packages/zoid-fs-client/package.json index 4d65706..6525036 100644 --- a/packages/zoid-fs-client/package.json +++ b/packages/zoid-fs-client/package.json @@ -4,7 +4,7 @@ "license": "MIT", "type": "module", "scripts": { - "start": "vite-node --watch index.ts /home/divyendusingh/zoid/vfs/1", + "start": "vite-node --watch index.ts /home/divyenduz/Documents/zoid/vfs/1", "test:prepare": "vite-node --watch index.ts /home/div/code/vfs/test-fs --tenant test", "ci:setup-fuse": "vite-node --watch index.ts", "test": "vitest",