@scp-dev/broker 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/index.d.ts +422 -0
- package/dist/index.js +2514 -0
- package/dist/testing.d.ts +30 -0
- package/dist/testing.js +131 -0
- package/package.json +48 -4
- package/src/bridge.ts +642 -0
- package/src/client.ts +336 -0
- package/src/index.ts +6 -0
- package/src/judge-gate.ts +552 -0
- package/src/lock.ts +113 -0
- package/src/ndjson.ts +95 -0
- package/src/paths.ts +132 -0
- package/src/server.ts +1124 -0
- package/src/socket.ts +57 -0
- package/src/sqlite.ts +90 -0
- package/src/testing.ts +168 -0
package/src/ndjson.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** the longest request line the broker reads, in bytes */
|
|
2
|
+
export const MAX_LINE = 256 * 1024
|
|
3
|
+
|
|
4
|
+
const NEWLINE = 0x0a
|
|
5
|
+
const decoder = new TextDecoder()
|
|
6
|
+
const encoder = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
/** splits a byte stream into UTF-8 lines; a multibyte character split across chunks stays whole */
|
|
9
|
+
export class LineReader {
|
|
10
|
+
private pending: Buffer = Buffer.alloc(0)
|
|
11
|
+
|
|
12
|
+
constructor(private readonly maxBytes: number) {}
|
|
13
|
+
|
|
14
|
+
/** bytes of a line that has not ended yet */
|
|
15
|
+
get buffered(): number {
|
|
16
|
+
return this.pending.length
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** the complete lines so far; `tooLong` once a line passes the cap (lines before it still count) */
|
|
20
|
+
push(chunk: Uint8Array): { lines: string[]; tooLong: boolean } {
|
|
21
|
+
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : Buffer.from(chunk)
|
|
22
|
+
const lines: string[] = []
|
|
23
|
+
for (let newline = this.pending.indexOf(NEWLINE); newline !== -1; newline = this.pending.indexOf(NEWLINE)) {
|
|
24
|
+
if (newline > this.maxBytes) return { lines, tooLong: true }
|
|
25
|
+
const line = decoder.decode(this.pending.subarray(0, newline))
|
|
26
|
+
this.pending = this.pending.subarray(newline + 1)
|
|
27
|
+
if (line.trim()) lines.push(line)
|
|
28
|
+
}
|
|
29
|
+
return { lines, tooLong: this.pending.length > this.maxBytes }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BATCH = 64 * 1024
|
|
34
|
+
|
|
35
|
+
/** queues lines as a list of chunks and writes as much as the socket takes, the rest on drain */
|
|
36
|
+
export class LineWriter {
|
|
37
|
+
private chunks: Uint8Array[] = []
|
|
38
|
+
private static readonly EMPTY = new Uint8Array(0)
|
|
39
|
+
private head = 0
|
|
40
|
+
private queued = 0
|
|
41
|
+
|
|
42
|
+
/** bytes waiting for the socket */
|
|
43
|
+
get pending(): number {
|
|
44
|
+
return this.queued
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
push(value: unknown): void {
|
|
48
|
+
this.pushBytes(encodeLine(value))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pushBytes(line: Uint8Array): void {
|
|
52
|
+
this.chunks.push(line)
|
|
53
|
+
this.queued += line.length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
flush(write: (bytes: Uint8Array) => number): void {
|
|
57
|
+
while (this.queued) {
|
|
58
|
+
const first = this.chunks[this.head]!
|
|
59
|
+
let batch: Uint8Array = first
|
|
60
|
+
if (first.length < BATCH && this.head + 1 < this.chunks.length) {
|
|
61
|
+
const parts: Uint8Array[] = []
|
|
62
|
+
let size = 0
|
|
63
|
+
for (let i = this.head; i < this.chunks.length && size < BATCH; i++) {
|
|
64
|
+
parts.push(this.chunks[i]!)
|
|
65
|
+
size += this.chunks[i]!.length
|
|
66
|
+
}
|
|
67
|
+
batch = Buffer.concat(parts, size)
|
|
68
|
+
}
|
|
69
|
+
const written = write(batch)
|
|
70
|
+
if (written <= 0) return
|
|
71
|
+
this.consume(written)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private consume(bytes: number): void {
|
|
76
|
+
this.queued -= bytes
|
|
77
|
+
while (bytes > 0) {
|
|
78
|
+
const first = this.chunks[this.head]!
|
|
79
|
+
if (bytes < first.length) {
|
|
80
|
+
this.chunks[this.head] = first.subarray(bytes)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
bytes -= first.length
|
|
84
|
+
this.chunks[this.head++] = LineWriter.EMPTY
|
|
85
|
+
}
|
|
86
|
+
if (this.head > 1024 || this.head === this.chunks.length) {
|
|
87
|
+
this.chunks = this.chunks.slice(this.head)
|
|
88
|
+
this.head = 0
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function encodeLine(value: unknown): Uint8Array {
|
|
94
|
+
return encoder.encode(`${JSON.stringify(value)}\n`)
|
|
95
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
|
|
4
|
+
import { homedir, hostname, userInfo } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
type Run = (cmd: string[]) => { exitCode: number | null; stdout: string | Buffer }
|
|
8
|
+
|
|
9
|
+
const runQuiet: Run = (cmd) => {
|
|
10
|
+
const result = spawnSync(cmd[0]!, cmd.slice(1), { stdio: ["ignore", "pipe", "ignore"], timeout: 2000 })
|
|
11
|
+
return { exitCode: result.status, stdout: result.stdout ?? "" }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** macOS's LocalHostName (read only), or undefined when it is unset */
|
|
15
|
+
export function localHostName(run: Run = runQuiet): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
const result = run(["scutil", "--get", "LocalHostName"])
|
|
18
|
+
return (result.exitCode === 0 && result.stdout.toString().trim()) || undefined
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** this machine's name: on macOS its LocalHostName, which a network change does not rename as it can the hostname; else the hostname */
|
|
25
|
+
export function systemHostname(
|
|
26
|
+
platform: string = process.platform,
|
|
27
|
+
local: () => string | undefined = localHostName,
|
|
28
|
+
fallback: () => string = hostname,
|
|
29
|
+
): string {
|
|
30
|
+
return (platform === "darwin" && local()) || fallback()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let host: string | undefined
|
|
34
|
+
|
|
35
|
+
/** systemHostname, read once per process */
|
|
36
|
+
export function thisHostname(): string {
|
|
37
|
+
host ??= systemHostname()
|
|
38
|
+
return host
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the lowercased first label of `name` (default this machine's): agents' machine label, and what `machine:` matches */
|
|
42
|
+
export function shortHostname(name: string = thisHostname()): string {
|
|
43
|
+
return (name.split(".")[0] ?? "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "unknown"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `$SCP_DIR`, else `$XDG_STATE_HOME/scp`, else ~/.local/state/scp */
|
|
47
|
+
export function relayDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
48
|
+
if (env.SCP_DIR) return env.SCP_DIR
|
|
49
|
+
return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), ".local", "state"), "scp")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `~/.config/scp/policy.json` of the account the broker runs as, from the user database rather
|
|
54
|
+
* than HOME or XDG_CONFIG_HOME, so the process that happens to start the broker cannot pick its policy
|
|
55
|
+
*/
|
|
56
|
+
export function policyPath(): string {
|
|
57
|
+
return join(accountHome(), ".config", "scp", "policy.json")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let home: string | undefined
|
|
61
|
+
let verified = false
|
|
62
|
+
|
|
63
|
+
/** whether accountHome came from the user database rather than HOME */
|
|
64
|
+
export function accountHomeVerified(): boolean {
|
|
65
|
+
accountHome()
|
|
66
|
+
return verified
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* the home directory the user database holds for `uid`. Node's userInfo reads it with getpwuid; Bun's answers from HOME,
|
|
71
|
+
* so under Bun it comes from the system's directory tools instead: dscacheutil on macOS, getent or /etc/passwd elsewhere
|
|
72
|
+
*/
|
|
73
|
+
export function userDatabaseHome(uid: number, options: { platform?: string; bun?: boolean; run?: Run; passwd?: () => string } = {}): string | undefined {
|
|
74
|
+
const platform = options.platform ?? process.platform
|
|
75
|
+
if (!(options.bun ?? typeof process.versions.bun === "string")) return userInfo().homedir || undefined
|
|
76
|
+
const run = options.run ?? runQuiet
|
|
77
|
+
if (platform === "darwin") {
|
|
78
|
+
const result = run(["dscacheutil", "-q", "user", "-a", "uid", String(uid)])
|
|
79
|
+
return (result.exitCode === 0 && /^dir: (.+)$/m.exec(result.stdout.toString())?.[1]?.trim()) || undefined
|
|
80
|
+
}
|
|
81
|
+
const entry = (text: string) => text.split("\n").find((line) => line.split(":")[2] === String(uid))
|
|
82
|
+
const result = run(["getent", "passwd", String(uid)])
|
|
83
|
+
let line = result.exitCode === 0 ? entry(result.stdout.toString()) : undefined
|
|
84
|
+
if (!line) {
|
|
85
|
+
try {
|
|
86
|
+
line = entry((options.passwd ?? (() => readFileSync("/etc/passwd", "utf8")))())
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
return line?.split(":")[5] || undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** the account's home directory from the user database, falling back to HOME where that is unavailable */
|
|
93
|
+
export function accountHome(): string {
|
|
94
|
+
if (home) return home
|
|
95
|
+
try {
|
|
96
|
+
if (typeof process.getuid === "function") home = userDatabaseHome(process.getuid())
|
|
97
|
+
verified = Boolean(home)
|
|
98
|
+
} catch {}
|
|
99
|
+
home ||= homedir()
|
|
100
|
+
return home
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SUN_PATH_MAX = 100
|
|
104
|
+
|
|
105
|
+
/** a private directory: created 0700, and refused when it is a link or someone else's */
|
|
106
|
+
export function privateDir(path: string): string {
|
|
107
|
+
mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
108
|
+
const stat = lstatSync(path)
|
|
109
|
+
if (!stat.isDirectory() || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
|
|
110
|
+
throw new Error(`${path} is not a directory this user owns`)
|
|
111
|
+
}
|
|
112
|
+
chmodSync(path, 0o700)
|
|
113
|
+
return path
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** whether `path` is a directory of this user's, not a link, that nobody else may write: where a socket can be trusted */
|
|
117
|
+
export function ownPrivateDir(path: string): boolean {
|
|
118
|
+
const stat = lstatSync(path, { throwIfNoEntry: false })
|
|
119
|
+
return !!stat && stat.isDirectory() && (typeof process.getuid !== "function" || stat.uid === process.getuid()) && (stat.mode & 0o022) === 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The broker's socket: inside the relay dir, unless that path is too long for a unix socket
|
|
124
|
+
* (deep temp HOMEs), then a per-user, per-dir private directory under /tmp.
|
|
125
|
+
*/
|
|
126
|
+
export function socketPath(dir: string): string {
|
|
127
|
+
const inside = join(dir, "relay.sock")
|
|
128
|
+
if (Buffer.byteLength(inside) <= SUN_PATH_MAX) return inside
|
|
129
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0
|
|
130
|
+
const key = createHash("sha256").update(dir).digest("hex").slice(0, 12)
|
|
131
|
+
return join("/tmp", `scp-${uid}-${key}`, "relay.sock")
|
|
132
|
+
}
|