Skip to content

Commit d03d6f9

Browse files
committed
working on adding conat fileserver support
1 parent 4cd8f3b commit d03d6f9

File tree

9 files changed

+301
-59
lines changed

9 files changed

+301
-59
lines changed

src/packages/conat/files/fs.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { type Client } from "@cocalc/conat/core/client";
2+
import { conat } from "@cocalc/conat/client";
3+
4+
export interface Filesystem {
5+
appendFile: (path: string, data: string | Buffer, encoding?) => Promise<void>;
6+
chmod: (path: string, mode: string | number) => Promise<void>;
7+
copyFile: (src: string, dest: string) => Promise<void>;
8+
cp: (src: string, dest: string, options?) => Promise<void>;
9+
exists: (path: string) => Promise<void>;
10+
link: (existingPath: string, newPath: string) => Promise<void>;
11+
mkdir: (path: string, options?) => Promise<void>;
12+
readFile: (path: string, encoding?: any) => Promise<string | Buffer>;
13+
readdir: (path: string) => Promise<string[]>;
14+
realpath: (path: string) => Promise<string>;
15+
rename: (oldPath: string, newPath: string) => Promise<void>;
16+
rm: (path: string, options?) => Promise<void>;
17+
rmdir: (path: string, options?) => Promise<void>;
18+
stat: (path: string) => Promise<Stats>;
19+
symlink: (target: string, path: string) => Promise<void>;
20+
truncate: (path: string, len?: number) => Promise<void>;
21+
unlink: (path: string) => Promise<void>;
22+
utimes: (
23+
path: string,
24+
atime: number | string | Date,
25+
mtime: number | string | Date,
26+
) => Promise<void>;
27+
writeFile: (path: string, data: string | Buffer) => Promise<void>;
28+
}
29+
30+
export interface Stats {
31+
dev: number;
32+
ino: number;
33+
mode: number;
34+
nlink: number;
35+
uid: number;
36+
gid: number;
37+
rdev: number;
38+
size: number;
39+
blksize: number;
40+
blocks: number;
41+
atimeMs: number;
42+
mtimeMs: number;
43+
ctimeMs: number;
44+
birthtimeMs: number;
45+
atime: Date;
46+
mtime: Date;
47+
ctime: Date;
48+
birthtime: Date;
49+
}
50+
51+
interface Options {
52+
service: string;
53+
client?: Client;
54+
fs: (subject?: string) => Promise<Filesystem>;
55+
}
56+
57+
export async function fsServer({ service, fs, client }: Options) {
58+
return await (client ?? conat()).service<Filesystem & { subject?: string }>(
59+
`${service}.*`,
60+
{
61+
async appendFile(path: string, data: string | Buffer, encoding?) {
62+
await (await fs(this.subject)).appendFile(path, data, encoding);
63+
},
64+
async chmod(path: string, mode: string | number) {
65+
await (await fs(this.subject)).chmod(path, mode);
66+
},
67+
async copyFile(src: string, dest: string) {
68+
await (await fs(this.subject)).copyFile(src, dest);
69+
},
70+
async cp(src: string, dest: string, options?) {
71+
await (await fs(this.subject)).cp(src, dest, options);
72+
},
73+
async exists(path: string) {
74+
await (await fs(this.subject)).exists(path);
75+
},
76+
async link(existingPath: string, newPath: string) {
77+
await (await fs(this.subject)).link(existingPath, newPath);
78+
},
79+
async mkdir(path: string, options?) {
80+
await (await fs(this.subject)).mkdir(path, options);
81+
},
82+
async readFile(path: string, encoding?) {
83+
return await (await fs(this.subject)).readFile(path, encoding);
84+
},
85+
async readdir(path: string) {
86+
return await (await fs(this.subject)).readdir(path);
87+
},
88+
async realpath(path: string) {
89+
return await (await fs(this.subject)).realpath(path);
90+
},
91+
async rename(oldPath: string, newPath: string) {
92+
return await (await fs(this.subject)).rename(oldPath, newPath);
93+
},
94+
async rm(path: string, options?) {
95+
return await (await fs(this.subject)).rm(path, options);
96+
},
97+
async rmdir(path: string, options?) {
98+
return await (await fs(this.subject)).rmdir(path, options);
99+
},
100+
async stat(path: string): Promise<Stats> {
101+
return await (await fs(this.subject)).stat(path);
102+
},
103+
async symlink(target: string, path: string) {
104+
return await (await fs(this.subject)).symlink(target, path);
105+
},
106+
async truncate(path: string, len?: number) {
107+
return await (await fs(this.subject)).truncate(path, len);
108+
},
109+
async unlink(path: string) {
110+
return await (await fs(this.subject)).unlink(path);
111+
},
112+
async utimes(
113+
path: string,
114+
atime: number | string | Date,
115+
mtime: number | string | Date,
116+
) {
117+
return await (await fs(this.subject)).utimes(path, atime, mtime);
118+
},
119+
async writeFile(path: string, data: string | Buffer) {
120+
return await (await fs(this.subject)).writeFile(path, data);
121+
},
122+
},
123+
);
124+
}
125+
126+
export function fsClient({
127+
client,
128+
subject,
129+
}: {
130+
client?: Client;
131+
subject: string;
132+
}) {
133+
return (client ?? conat()).call<Filesystem>(subject);
134+
}

src/packages/file-server/btrfs/subvolume-bup.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class SubvolumeBup {
4646
`createBackup: creating ${BUP_SNAPSHOT} to get a consistent backup`,
4747
);
4848
await this.subvolume.snapshots.create(BUP_SNAPSHOT);
49-
const target = this.subvolume.normalize(
49+
const target = this.subvolume.fs.safeAbsPath(
5050
this.subvolume.snapshots.path(BUP_SNAPSHOT),
5151
);
5252

@@ -133,7 +133,9 @@ export class SubvolumeBup {
133133
return v;
134134
}
135135

136-
path = normalize(path);
136+
path = this.subvolume.fs
137+
.safeAbsPath(path)
138+
.slice(this.subvolume.path.length);
137139
const { stdout } = await sudo({
138140
command: "bup",
139141
args: [

src/packages/file-server/btrfs/subvolume.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ A subvolume
44

55
import { type Filesystem, DEFAULT_SUBVOLUME_SIZE } from "./filesystem";
66
import refCache from "@cocalc/util/refcache";
7-
import { sudo } from "./util";
8-
import { join, normalize } from "path";
7+
import { isdir, sudo } from "./util";
8+
import { join } from "path";
99
import { SubvolumeBup } from "./subvolume-bup";
1010
import { SubvolumeSnapshots } from "./subvolume-snapshots";
1111
import { SubvolumeQuota } from "./subvolume-quota";
12-
import { SandboxedFilesystem } from "../fs";
12+
import { SandboxedFilesystem } from "../fs/sandbox";
1313
import { exists } from "@cocalc/backend/misc/async-utils-node";
1414

1515
import getLogger from "@cocalc/backend/logger";
@@ -77,11 +77,35 @@ export class Subvolume {
7777
});
7878
};
7979

80-
// this should provide a path that is guaranteed to be
81-
// inside this.path on the filesystem or throw error
82-
// [ ] TODO: not sure if the code here is sufficient!!
83-
normalize = (path: string) => {
84-
return join(this.path, normalize(path));
80+
rsync = async ({
81+
src,
82+
target,
83+
timeout = 5 * 60 * 1000,
84+
}: {
85+
src: string;
86+
target: string;
87+
timeout?: number;
88+
}): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
89+
let srcPath = this.fs.safeAbsPath(src);
90+
let targetPath = this.fs.safeAbsPath(target);
91+
if (src.endsWith("/")) {
92+
srcPath += "/";
93+
}
94+
if (target.endsWith("/")) {
95+
targetPath += "/";
96+
}
97+
if (!srcPath.endsWith("/") && (await isdir(srcPath))) {
98+
srcPath += "/";
99+
if (!targetPath.endsWith("/")) {
100+
targetPath += "/";
101+
}
102+
}
103+
return await sudo({
104+
command: "rsync",
105+
args: [srcPath, targetPath],
106+
err_on_exit: false,
107+
timeout: timeout / 1000,
108+
});
85109
};
86110
}
87111

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { fsServer } from "@cocalc/conat/files/fs";
2+
import { conat } from "@cocalc/backend/conat";
3+
import { SandboxedFilesystem } from "@cocalc/file-server/fs/sandbox";
4+
import { mkdir } from "fs/promises";
5+
import { join } from "path";
6+
import { isValidUUID } from "@cocalc/util/misc";
7+
8+
export function localPathFileserver({
9+
service,
10+
path,
11+
}: {
12+
service: string;
13+
path: string;
14+
}) {
15+
const client = conat();
16+
const server = fsServer({
17+
service,
18+
client,
19+
fs: async (subject: string) => {
20+
const project_id = getProjectId(subject);
21+
const p = join(path, project_id);
22+
try {
23+
await mkdir(p);
24+
} catch {}
25+
return new SandboxedFilesystem(p);
26+
},
27+
});
28+
return server;
29+
}
30+
31+
function getProjectId(subject: string) {
32+
const v = subject.split(".");
33+
if (v.length != 2) {
34+
throw Error("subject must have 2 segments");
35+
}
36+
if (!v[1].startsWith("project-")) {
37+
throw Error("second segment of subject must start with 'project-'");
38+
}
39+
const project_id = v[1].slice("project-".length);
40+
if (!isValidUUID(project_id)) {
41+
throw Error("not a valid project id");
42+
}
43+
return project_id;
44+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
describe("use the simple fileserver", () => {
2+
it("does nothing", async () => {});
3+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { SandboxedFilesystem } from "./sandbox";
2+
import { mkdtemp, mkdir, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "path";
5+
6+
let tempDir;
7+
beforeAll(async () => {
8+
tempDir = await mkdtemp(join(tmpdir(), "cocalc"));
9+
});
10+
11+
describe("test using the filesystem sandbox to do a few standard things", () => {
12+
let fs;
13+
it("creates and reads file", async () => {
14+
await mkdir(join(tempDir, "test-1"));
15+
fs = new SandboxedFilesystem(join(tempDir, "test-1"));
16+
await fs.writeFile("a", "hi");
17+
const r = await fs.readFile("a", "utf8");
18+
expect(r).toEqual("hi");
19+
});
20+
21+
it("truncate file", async () => {
22+
await fs.writeFile("b", "hello");
23+
await fs.truncate("b", 4);
24+
const r = await fs.readFile("b", "utf8");
25+
expect(r).toEqual("hell");
26+
});
27+
});
28+
29+
describe("make various attempts to break out of the sandbox", () => {
30+
let fs;
31+
it("creates sandbox", async () => {
32+
await mkdir(join(tempDir, "test-2"));
33+
fs = new SandboxedFilesystem(join(tempDir, "test-2"));
34+
await fs.writeFile("x", "hi");
35+
});
36+
37+
it("obvious first attempt to escape fails", async () => {
38+
const v = await fs.readdir("..");
39+
expect(v).toEqual(["x"]);
40+
});
41+
42+
it("obvious first attempt to escape fails", async () => {
43+
const v = await fs.readdir("a/../..");
44+
expect(v).toEqual(["x"]);
45+
});
46+
47+
it("another attempt", async () => {
48+
await fs.copyFile("/x", "/tmp");
49+
const v = await fs.readdir("a/../..");
50+
expect(v).toEqual(["tmp", "x"]);
51+
52+
const r = await fs.readFile("tmp", "utf8");
53+
expect(r).toEqual("hi");
54+
});
55+
});
56+
57+
afterAll(async () => {
58+
await rm(tempDir, { force: true, recursive: true });
59+
});

0 commit comments

Comments
 (0)