@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/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
+ }