metergraph-cli 0.0.0-stage → 0.2.0-preview.0
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 +876 -2
- package/assets/skill/SKILL.md +73 -0
- package/assets/skill/manifest.json +9 -0
- package/bin/metergraph.js +11 -0
- package/package.json +44 -4
- package/src/args.js +486 -0
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +166 -0
- package/src/constants.js +176 -0
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +208 -0
- package/src/http.js +129 -0
- package/src/origin.js +37 -0
- package/src/output.js +726 -0
- package/src/read-contract.js +601 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +317 -0
- package/src/skill-bundle.js +53 -0
- package/src/skill.js +449 -0
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
package/src/constants.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const packageJson = JSON.parse(
|
|
4
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
export const PACKAGE_NAME = packageJson.name;
|
|
8
|
+
export const VERSION = packageJson.version;
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
export const DEFAULT_ORIGIN = "https://app.metergraph.dev";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
|
+
export const MIN_TIMEOUT_MS = 100;
|
|
15
|
+
export const MAX_TIMEOUT_MS = 30000;
|
|
16
|
+
export const MAX_BODY_BYTES = 32 * 1024;
|
|
17
|
+
|
|
18
|
+
// The public guide a person follows to connect an application to Metergraph.
|
|
19
|
+
// It is the next action wherever the CLI itself cannot help, such as doctor
|
|
20
|
+
// results and sign in from a cloud runtime.
|
|
21
|
+
export const CONNECTION_GUIDE_URL =
|
|
22
|
+
"https://www.metergraph.dev/docs/guides/agent-access/";
|
|
23
|
+
|
|
24
|
+
// Deployment profiles recognized by the probe contract. Any other value,
|
|
25
|
+
// including a well-formed one, is reported as unsupported and never echoed.
|
|
26
|
+
export const SUPPORTED_PROFILES = Object.freeze([
|
|
27
|
+
"local",
|
|
28
|
+
"managed",
|
|
29
|
+
"byoc-core",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Clients that load project skills from a native directory, from each
|
|
33
|
+
// client's own documentation. Every client has its own directory, so
|
|
34
|
+
// installing for one never touches another client's files.
|
|
35
|
+
export const SKILL_CLIENTS = Object.freeze({
|
|
36
|
+
codex: Object.freeze({ label: "Codex", dir: ".agents" }),
|
|
37
|
+
claude: Object.freeze({ label: "Claude Code", dir: ".claude" }),
|
|
38
|
+
cursor: Object.freeze({ label: "Cursor", dir: ".cursor" }),
|
|
39
|
+
});
|
|
40
|
+
export const SKILL_RUNTIMES = Object.freeze(["local", "cloud"]);
|
|
41
|
+
|
|
42
|
+
// Recognized values that cannot load project skill files. They get a pointer
|
|
43
|
+
// to the connection guide instead of a usage error, and nothing is written.
|
|
44
|
+
export const HANDOFF_SKILL_CLIENTS = Object.freeze({
|
|
45
|
+
"claude-desktop": "Claude Desktop",
|
|
46
|
+
chatgpt: "ChatGPT",
|
|
47
|
+
});
|
|
48
|
+
export const HANDOFF_SKILL_RUNTIMES = Object.freeze(["cloud-no-shell"]);
|
|
49
|
+
|
|
50
|
+
// Sign in needs a browser on the same machine as the CLI, because the browser
|
|
51
|
+
// hands the authorization back to a loopback listener. Other runtimes are
|
|
52
|
+
// recognized so they get a handoff instead of a usage error.
|
|
53
|
+
export const LOGIN_RUNTIMES = Object.freeze(["local"]);
|
|
54
|
+
export const HANDOFF_LOGIN_RUNTIMES = Object.freeze(["cloud", "cloud-no-shell"]);
|
|
55
|
+
|
|
56
|
+
// How long login waits for the browser to finish, and the fixed deadline for
|
|
57
|
+
// each group of HTTP requests around it.
|
|
58
|
+
export const LOGIN_DEFAULT_TIMEOUT_MS = 300000;
|
|
59
|
+
export const LOGIN_MIN_TIMEOUT_MS = 1000;
|
|
60
|
+
export const LOGIN_MAX_TIMEOUT_MS = 900000;
|
|
61
|
+
export const AUTH_HTTP_TIMEOUT_MS = 15000;
|
|
62
|
+
|
|
63
|
+
// The only scope this CLI requests. It never asks for agent:read (Debug) or
|
|
64
|
+
// agent:replay, and never falls back to them.
|
|
65
|
+
export const METADATA_SCOPE = "agent:metadata";
|
|
66
|
+
|
|
67
|
+
// The service's agent access contract version, as sent in schema_version by
|
|
68
|
+
// GET /v1/agent/workspace and GET /v1/agent/capabilities. It is the
|
|
69
|
+
// service's own string and is unrelated to SCHEMA_VERSION, the version of
|
|
70
|
+
// this CLI's JSON output.
|
|
71
|
+
export const AGENT_CONTRACT_VERSION = "metergraph.agent-access/v1";
|
|
72
|
+
|
|
73
|
+
// Exact paths on the chosen origin. Discovered metadata must name exactly
|
|
74
|
+
// these, so a tampered document cannot send the CLI anywhere else.
|
|
75
|
+
export const AUTH_PATHS = Object.freeze({
|
|
76
|
+
resource: "/v1/agent/mcp",
|
|
77
|
+
issuer: "/v1/oauth",
|
|
78
|
+
authorization: "/v1/oauth/authorize",
|
|
79
|
+
token: "/v1/oauth/token",
|
|
80
|
+
registration: "/v1/oauth/register",
|
|
81
|
+
revocation: "/v1/oauth/revoke",
|
|
82
|
+
signup: "/v1/auth/signup",
|
|
83
|
+
workspace: "/v1/agent/workspace",
|
|
84
|
+
capabilities: "/v1/agent/capabilities",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Bearer-protected Metadata read endpoints. They are fixed paths on the bound
|
|
88
|
+
// origin; the transport accepts a query string only on the paths listed in
|
|
89
|
+
// READ_QUERY_KEYS, and only with those keys. GET /v1/agent/routes takes no
|
|
90
|
+
// query at all, so its rows are bounded by the response size limit and cut to
|
|
91
|
+
// --limit locally.
|
|
92
|
+
export const READ_PATHS = Object.freeze({
|
|
93
|
+
usage: "/v1/agent/usage",
|
|
94
|
+
routes: "/v1/agent/routes",
|
|
95
|
+
traces: "/v1/agent/traces",
|
|
96
|
+
});
|
|
97
|
+
export const READ_QUERY_KEYS = Object.freeze({
|
|
98
|
+
"/v1/agent/usage": Object.freeze(["days", "limit"]),
|
|
99
|
+
"/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor"]),
|
|
100
|
+
"/v1/cli/setup/credential": Object.freeze(["family_id"]),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Read commands share one total deadline for every request they make,
|
|
104
|
+
// including a token refresh. It is never reset per request or page.
|
|
105
|
+
export const READ_DEFAULT_TIMEOUT_MS = 15000;
|
|
106
|
+
export const READ_MIN_TIMEOUT_MS = 1000;
|
|
107
|
+
export const READ_MAX_TIMEOUT_MS = 60000;
|
|
108
|
+
// A client bound below the service's own max_response_bytes. A larger body is
|
|
109
|
+
// discarded and reported as response_too_large, never read further.
|
|
110
|
+
export const READ_MAX_BYTES = 1024 * 1024;
|
|
111
|
+
export const READ_MAX_DAYS = 90;
|
|
112
|
+
export const READ_MAX_LIMIT = 200;
|
|
113
|
+
export const READ_DEFAULT_DAYS = 7;
|
|
114
|
+
export const READ_DEFAULT_LIMIT = 50;
|
|
115
|
+
export const TRACES_DEFAULT_LIMIT = 20;
|
|
116
|
+
export const MAX_CURSOR_LENGTH = 512;
|
|
117
|
+
export const MAX_FILTER_LENGTH = 256;
|
|
118
|
+
|
|
119
|
+
// One exit code per outcome. Documented in README.md; changing a value is a
|
|
120
|
+
// breaking change for scripts. New outcomes are appended.
|
|
121
|
+
export const EXIT_CODES = Object.freeze({
|
|
122
|
+
ok: 0,
|
|
123
|
+
internal_error: 1,
|
|
124
|
+
invalid_input: 2,
|
|
125
|
+
authentication_required: 3,
|
|
126
|
+
connection_failed: 4,
|
|
127
|
+
unhealthy: 5,
|
|
128
|
+
unsupported: 6,
|
|
129
|
+
redirect_rejected: 7,
|
|
130
|
+
conflict: 8,
|
|
131
|
+
filesystem_error: 9,
|
|
132
|
+
authorization_failed: 10,
|
|
133
|
+
verification_failed: 11,
|
|
134
|
+
login_required: 12,
|
|
135
|
+
revocation_unconfirmed: 13,
|
|
136
|
+
capability_unavailable: 14,
|
|
137
|
+
permission_denied: 15,
|
|
138
|
+
rate_limited: 16,
|
|
139
|
+
cancelled: 17,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const EXIT_CODE_MEANINGS = Object.freeze({
|
|
143
|
+
ok: "Command succeeded. Doctor does not return this in this preview.",
|
|
144
|
+
internal_error: "Unexpected internal failure in the CLI.",
|
|
145
|
+
invalid_input: "Unknown command or argument, or an invalid option value. No request was made.",
|
|
146
|
+
authentication_required:
|
|
147
|
+
"Service is reachable, healthy and supported, and requires authentication. No workspace is connected.",
|
|
148
|
+
connection_failed: "The origin could not be reached, the connection failed, or the probe timed out.",
|
|
149
|
+
unhealthy:
|
|
150
|
+
"The service answered but reported that it is not healthy, or answered with a server error.",
|
|
151
|
+
unsupported:
|
|
152
|
+
"The service answered with a response, deployment profile or status this CLI does not support, " +
|
|
153
|
+
"or the skill client or runtime cannot use project skill files, " +
|
|
154
|
+
"or sign in cannot run in this environment. Nothing was written.",
|
|
155
|
+
redirect_rejected: "The service answered with a redirect. Redirects are never followed.",
|
|
156
|
+
conflict:
|
|
157
|
+
"The skill target is not owned by this CLI, was modified, is unsafe, is locked or needs an explicit update, " +
|
|
158
|
+
"or the project is bound to a different origin or workspace. Nothing was changed.",
|
|
159
|
+
filesystem_error:
|
|
160
|
+
"Project or credential files could not be read or written. Partial changes were rolled back unless the message says otherwise.",
|
|
161
|
+
authorization_failed:
|
|
162
|
+
"Browser authorization did not finish: it was denied, cancelled, timed out or returned an invalid callback. Nothing was saved.",
|
|
163
|
+
verification_failed:
|
|
164
|
+
"The service issued a grant that does not match the requested origin, workspace, client, resource or Metadata scope. Nothing was saved.",
|
|
165
|
+
login_required:
|
|
166
|
+
"No usable sign in for this project: none was saved, it expired, was revoked, lost access or could not be refreshed safely. Run login again.",
|
|
167
|
+
revocation_unconfirmed:
|
|
168
|
+
"Local credentials were removed, but the service did not confirm that the grant was revoked.",
|
|
169
|
+
capability_unavailable:
|
|
170
|
+
"The service does not make this read available to the project's Metadata grant. No data was read.",
|
|
171
|
+
permission_denied:
|
|
172
|
+
"The service refused this read for the signed in grant, for example for a missing scope or permission.",
|
|
173
|
+
rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
|
|
174
|
+
cancelled:
|
|
175
|
+
"A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
|
|
176
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Reads the separate Metadata agent credential for deployment verification
|
|
5
|
+
// from one explicit, absolute, private file. There is no argv, stdin or
|
|
6
|
+
// environment variable form. The file is read, never copied, written or
|
|
7
|
+
// persisted, and the token is returned only to the verifier that asked for
|
|
8
|
+
// it. Results are { ok: true, token } or { ok: false, outcome, reason } with
|
|
9
|
+
// fixed tokens: no path, file content or system error text is ever returned.
|
|
10
|
+
//
|
|
11
|
+
// The file must be a regular file owned by the current user with mode 0600
|
|
12
|
+
// and exactly one link, holding one bearer token and at most one trailing
|
|
13
|
+
// newline. No component of the path may be a symbolic link, so callers pass
|
|
14
|
+
// the canonical path (on macOS, /private/var/... rather than /var/...). The
|
|
15
|
+
// directory holding the file must be owned by the current user or root and
|
|
16
|
+
// must not be writable by group or others. Higher directories may be writable
|
|
17
|
+
// by others only when the sticky bit is set, which is how shared temporary
|
|
18
|
+
// directories such as /tmp are protected; the file's own directory gets no
|
|
19
|
+
// such allowance.
|
|
20
|
+
//
|
|
21
|
+
// Windows has no owner-only mode bits to check, and this CLI has no proof of
|
|
22
|
+
// a file's ACL, so the read fails closed there with a platform handoff.
|
|
23
|
+
|
|
24
|
+
const MAX_PATH_LENGTH = 4096;
|
|
25
|
+
// No token is shorter than this, so a fixed string that is shorter can never
|
|
26
|
+
// contain one.
|
|
27
|
+
export const CREDENTIAL_MIN_LENGTH = 16;
|
|
28
|
+
const TOKEN = new RegExp(`^[\\x21-\\x7e]{${CREDENTIAL_MIN_LENGTH},8192}$`);
|
|
29
|
+
// The longest token plus a CRLF line ending.
|
|
30
|
+
export const CREDENTIAL_MAX_BYTES = 8192 + 2;
|
|
31
|
+
|
|
32
|
+
const OPEN_FLAGS =
|
|
33
|
+
fs.constants.O_RDONLY |
|
|
34
|
+
(fs.constants.O_NOFOLLOW ?? 0) |
|
|
35
|
+
// A FIFO opened without O_NONBLOCK blocks until a writer appears.
|
|
36
|
+
(fs.constants.O_NONBLOCK ?? 0) |
|
|
37
|
+
(fs.constants.O_NOCTTY ?? 0);
|
|
38
|
+
|
|
39
|
+
const fail = (reason) => ({ ok: false, outcome: "filesystem_error", reason });
|
|
40
|
+
|
|
41
|
+
// options.afterOpen and options.afterRead are test seams only: afterOpen runs
|
|
42
|
+
// after the file is opened and before it is checked, afterRead after the
|
|
43
|
+
// content is read and before it is checked again, so a test can replace,
|
|
44
|
+
// rewrite or chmod the file in between.
|
|
45
|
+
export function readCredentialFile(file, { platform = process.platform, afterOpen = null, afterRead = null } = {}) {
|
|
46
|
+
if (platform === "win32") return { ok: false, outcome: "unsupported", reason: "platform_credential_handoff" };
|
|
47
|
+
if (!validPath(file)) return fail("credential_path_invalid");
|
|
48
|
+
const uid = process.getuid();
|
|
49
|
+
|
|
50
|
+
const parents = checkParents(file, uid);
|
|
51
|
+
if (!parents.ok) return parents;
|
|
52
|
+
|
|
53
|
+
let before;
|
|
54
|
+
try {
|
|
55
|
+
before = fs.lstatSync(file, { bigint: true });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return fail(error?.code === "ENOENT" ? "credential_missing" : "credential_unreadable");
|
|
58
|
+
}
|
|
59
|
+
if (before.isSymbolicLink()) return fail("credential_symlink");
|
|
60
|
+
const problem = credentialStatProblem(before, uid);
|
|
61
|
+
if (problem !== null) return fail(problem);
|
|
62
|
+
|
|
63
|
+
let fd;
|
|
64
|
+
try {
|
|
65
|
+
fd = fs.openSync(file, OPEN_FLAGS);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ELOOP") return fail("credential_symlink");
|
|
68
|
+
return fail(error?.code === "ENOENT" ? "credential_changed" : "credential_unreadable");
|
|
69
|
+
}
|
|
70
|
+
let buffer = null;
|
|
71
|
+
try {
|
|
72
|
+
if (afterOpen !== null) afterOpen();
|
|
73
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
74
|
+
// No links left means the name was moved away or replaced after the open.
|
|
75
|
+
if (opened.nlink === 0n) return fail("credential_changed");
|
|
76
|
+
const openedProblem = credentialStatProblem(opened, uid);
|
|
77
|
+
if (openedProblem !== null) return fail(openedProblem);
|
|
78
|
+
if (!sameInode(opened, before)) return fail("credential_changed");
|
|
79
|
+
|
|
80
|
+
buffer = Buffer.alloc(CREDENTIAL_MAX_BYTES + 1);
|
|
81
|
+
let length = 0;
|
|
82
|
+
while (length < buffer.length) {
|
|
83
|
+
const count = fs.readSync(fd, buffer, length, buffer.length - length, length);
|
|
84
|
+
if (count === 0) break;
|
|
85
|
+
length += count;
|
|
86
|
+
}
|
|
87
|
+
if (length > CREDENTIAL_MAX_BYTES) return fail("credential_too_large");
|
|
88
|
+
if (afterRead !== null) afterRead();
|
|
89
|
+
|
|
90
|
+
// After the read the file must still pass every check and be the same
|
|
91
|
+
// file with the same metadata. Rewriting it in place, even with content
|
|
92
|
+
// of the same length, or changing its mode, owner or links moves ctime or
|
|
93
|
+
// mtime, which are compared to the nanosecond.
|
|
94
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
95
|
+
if (after.nlink === 0n) return fail("credential_changed");
|
|
96
|
+
const afterProblem = credentialStatProblem(after, uid);
|
|
97
|
+
if (afterProblem !== null) return fail(afterProblem);
|
|
98
|
+
if (!unchanged(after, opened) || after.size !== BigInt(length)) return fail("credential_changed");
|
|
99
|
+
let current;
|
|
100
|
+
try {
|
|
101
|
+
current = fs.lstatSync(file, { bigint: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return fail("credential_changed");
|
|
104
|
+
}
|
|
105
|
+
if (!unchanged(current, opened)) return fail("credential_changed");
|
|
106
|
+
// Every directory above it must still be the ones checked.
|
|
107
|
+
const again = checkParents(file, uid);
|
|
108
|
+
if (!again.ok) return again;
|
|
109
|
+
if (again.inodes.some((inode, index) => !sameInode(inode, parents.inodes[index]))) {
|
|
110
|
+
return fail("credential_changed");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parseToken(buffer.subarray(0, length));
|
|
114
|
+
} catch {
|
|
115
|
+
return fail("credential_unreadable");
|
|
116
|
+
} finally {
|
|
117
|
+
if (buffer !== null) buffer.fill(0);
|
|
118
|
+
fs.closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Returns a fixed reason when a stat of the credential file is not a private,
|
|
123
|
+
// owner-only, singly linked regular file of a usable size, otherwise null.
|
|
124
|
+
// Accepts number or bigint stats. Exported for tests that cannot create files
|
|
125
|
+
// owned by another user.
|
|
126
|
+
export function credentialStatProblem(stat, uid) {
|
|
127
|
+
if (!stat.isFile()) return "credential_not_regular";
|
|
128
|
+
if (Number(stat.uid) !== uid) return "credential_not_owner";
|
|
129
|
+
if ((Number(stat.mode) & 0o7777) !== 0o600) return "credential_permissions_unsafe";
|
|
130
|
+
if (Number(stat.nlink) !== 1) return "credential_hardlinked";
|
|
131
|
+
if (Number(stat.size) === 0) return "credential_empty";
|
|
132
|
+
if (Number(stat.size) > CREDENTIAL_MAX_BYTES) return "credential_too_large";
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validPath(file) {
|
|
137
|
+
return (
|
|
138
|
+
typeof file === "string" &&
|
|
139
|
+
file.length > 1 &&
|
|
140
|
+
file.length <= MAX_PATH_LENGTH &&
|
|
141
|
+
!file.includes("\0") &&
|
|
142
|
+
path.isAbsolute(file) &&
|
|
143
|
+
path.normalize(file) === file &&
|
|
144
|
+
!file.endsWith(path.sep)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Checks every directory from the root down to the file's own directory.
|
|
149
|
+
// Returns { ok: true, inodes } so a second walk can prove nothing moved.
|
|
150
|
+
function checkParents(file, uid) {
|
|
151
|
+
const directories = [];
|
|
152
|
+
for (let dir = path.dirname(file); ; dir = path.dirname(dir)) {
|
|
153
|
+
directories.unshift(dir);
|
|
154
|
+
if (path.dirname(dir) === dir) break;
|
|
155
|
+
}
|
|
156
|
+
const inodes = [];
|
|
157
|
+
for (const [index, dir] of directories.entries()) {
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = fs.lstatSync(dir);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return fail(error?.code === "ENOENT" ? "credential_missing" : "credential_unreadable");
|
|
163
|
+
}
|
|
164
|
+
if (stat.isSymbolicLink()) return fail("credential_path_symlink");
|
|
165
|
+
if (!stat.isDirectory()) return fail("credential_path_invalid");
|
|
166
|
+
if (stat.uid !== uid && stat.uid !== 0) return fail("credential_parent_unsafe");
|
|
167
|
+
const shared = (stat.mode & 0o022) !== 0;
|
|
168
|
+
const own = index === directories.length - 1;
|
|
169
|
+
if (shared && (own || (stat.mode & 0o1000) === 0)) return fail("credential_parent_unsafe");
|
|
170
|
+
inodes.push(stat);
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, inodes };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sameInode(a, b) {
|
|
176
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The same file with the same owner, mode, links, size and change times.
|
|
180
|
+
// Both are bigint stats, so times compare to the nanosecond.
|
|
181
|
+
function unchanged(a, b) {
|
|
182
|
+
return (
|
|
183
|
+
sameInode(a, b) &&
|
|
184
|
+
a.uid === b.uid &&
|
|
185
|
+
a.mode === b.mode &&
|
|
186
|
+
a.nlink === b.nlink &&
|
|
187
|
+
a.size === b.size &&
|
|
188
|
+
a.mtimeNs === b.mtimeNs &&
|
|
189
|
+
a.ctimeNs === b.ctimeNs
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseToken(bytes) {
|
|
194
|
+
let text;
|
|
195
|
+
try {
|
|
196
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
197
|
+
} catch {
|
|
198
|
+
return fail("credential_malformed");
|
|
199
|
+
}
|
|
200
|
+
if (text.endsWith("\r\n")) text = text.slice(0, -2);
|
|
201
|
+
else if (text.endsWith("\n")) text = text.slice(0, -1);
|
|
202
|
+
if (text.length === 0) return fail("credential_empty");
|
|
203
|
+
// One token only: any whitespace means a second token, a label such as
|
|
204
|
+
// "Bearer" or a second line.
|
|
205
|
+
if (!TOKEN.test(text)) return fail("credential_malformed");
|
|
206
|
+
return { ok: true, token: text };
|
|
207
|
+
}
|