deepdebug-local-agent 1.5.0 → 1.7.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/docs/la-protocol-v2.md +70 -16
- package/env.example +6 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/config.js +10 -2
- package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
- package/src/la-protocol-v2/content-handlers/egress.js +22 -0
- package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
- package/src/la-protocol-v2/content-handlers/handler.js +52 -16
- package/src/la-protocol-v2/content-handlers/install.js +35 -15
- package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
- package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +87 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +12 -2
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
- package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +669 -0
- package/test/la-protocol-v2/content-deny-list.test.js +138 -0
- package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
- package/test/la-protocol-v2/content-file-list.test.js +385 -0
- package/test/la-protocol-v2/content-handlers.test.js +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +10 -5
- package/test/la-protocol-v2/content-wiring.test.js +39 -14
- package/test/la-protocol-v2/contract.test.js +15 -5
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
- package/tools/interop/la-interop-list-client.mjs +93 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// F3-07c: the narrowed deny list (Edu, 2026-11-03): block the files that ARE credentials, serve the code that merely talks about them.
|
|
2
|
+
// `credentials*` used to block credentials.service.ts; now a name that mentions credential/kubeconfig is served only when its last
|
|
3
|
+
// extension is source code. The DLP (enforce, mandatory) stays the second layer for whatever is served.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { isSensitivePath, resolveInside, CODE_EXTENSIONS } from "../../src/la-protocol-v2/content-handlers/path-guard.js";
|
|
9
|
+
import { ContentRefRegistry } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
|
|
10
|
+
import { setup, write, tmpDir } from "./helpers/content-rig.js";
|
|
11
|
+
|
|
12
|
+
// [path, sensitive?] Every row is one name the guard must classify; "dir" rows are listing entries that are folders.
|
|
13
|
+
const FILES = [
|
|
14
|
+
// ---- served: code and docs whose NAME mentions a credential (the reason for the change)
|
|
15
|
+
["credentials.service.ts", false], ["src/auth/credentials.service.ts", false], ["CredentialsController.java", false],
|
|
16
|
+
["src/main/java/io/x/CredentialsController.java", false], ["credentials_test.py", false], ["tests/test_credentials.py", false],
|
|
17
|
+
["credentials.js", false], ["credentials.mjs", false], ["credentials.tsx", false], ["credentialsHelper.go", false],
|
|
18
|
+
["credentials_store.rs", false], ["CredentialProvider.kt", false], ["credentials.rb", false], ["Credentials.cs", false],
|
|
19
|
+
["credentials.php", false], ["credentials.vue", false], ["credentials.sql", false], ["credentials.md", false],
|
|
20
|
+
["docs/credentials.md", false], ["credentials.spec.ts", false], ["credentials.d.ts", false], ["aws-credentials-loader.ts", false],
|
|
21
|
+
["credential-store.js", false], ["CREDENTIALS.SERVICE.TS", false], ["credentials.service.ts.", false], ["credentials.html", false],
|
|
22
|
+
// ---- served: look-alikes that are not about secrets at all
|
|
23
|
+
["env.ts", false], ["env.d.ts", false], ["env.mjs", false], ["env.config.js", false], ["src/env/index.js", false], ["environment.ts", false],
|
|
24
|
+
["src/environment.js", false], ["kubeconfig.go", false], ["KubeconfigLoader.java", false], ["kubeconfig_loader.py", false],
|
|
25
|
+
["secrets.service.ts", false], ["SecretsManager.java", false], ["README.md", false], ["package.json", false], ["src/keyboard.js", false],
|
|
26
|
+
["docs/pem-format.md", false], ["tsconfig.json", false], ["src/secretary.js", false], [".gitignore", false], [".github/workflows/ci.yml", false],
|
|
27
|
+
["src/credentials/index.ts", false], ["src/credentials/CredentialsService.java", false], ["env.ts.snap.ts", false], [".environment.ts", false],
|
|
28
|
+
// ---- blocked: files that ARE credentials (Edu's list)
|
|
29
|
+
["credentials", true], [".credentials", true], ["credentials.json", true], ["credentials.yml", true], ["credentials.yaml", true],
|
|
30
|
+
["credentials.xml", true], ["credentials.toml", true], ["credentials.ini", true], ["credentials.cfg", true], ["credentials.conf", true],
|
|
31
|
+
["credentials.csv", true], ["credentials.txt", true], ["credentials.properties", true], ["credentials.db", true], ["credentials.sqlite", true],
|
|
32
|
+
["credentials.enc", true], ["credentials.pem", true], ["credentials.p12", true], ["credentials.pfx", true], ["credentials.kdbx", true],
|
|
33
|
+
["credentials.tfvars", true], ["credentials.env", true], ["application_default_credentials.json", true], ["x.credentials", true],
|
|
34
|
+
["aws/credentials", true], [".aws/credentials", true], ["gcloud/credentials.db", true], [".config/gcloud/credentials.db", true],
|
|
35
|
+
["CREDENTIALS.JSON", true], ["Credentials.Yml", true], ["deep/er/credentials.json", true], ["gcp-credentials.json", true],
|
|
36
|
+
["credentials.prod.json", true], ["credentials.json.bak", true], ["credentials.bak", true], ["credentials~", true], ["credentials-prod", true],
|
|
37
|
+
["credential.json", true], ["credentials.sh", true], ["credentials.service.json", true], ["credentials.json.", true],
|
|
38
|
+
["credentials/prod.json", true], ["src/credentials/README", true], ["src/credentials/key.txt", true], [".credentials/token", true],
|
|
39
|
+
// ---- blocked: env files and their variants (the gaps the reviewer found)
|
|
40
|
+
[".env", true], [".env.local", true], [".env.production", true], [".env.production.local", true], [".envrc", true], [".envrc.local", true],
|
|
41
|
+
[".env_backup", true], [".env-prod", true], [".env.local~", true], [".env~", true], [".env#", true], ["env.production", true], ["env.local", true],
|
|
42
|
+
["env_prod", true], ["env-staging", true], ["env", true], ["prod.env", true], ["app/.ENV", true], [".env2", true], [".envs/.production/.postgres", true],
|
|
43
|
+
// ---- blocked: kube, secrets, keys, npm/pip/net, docker, git, ssh
|
|
44
|
+
["kubeconfig", true], ["kubeconfig-prod", true], ["kubeconfig_dev", true], ["kubeconfig.yaml", true], ["prod.kubeconfig", true], [".kube/config", true],
|
|
45
|
+
["secrets/api.txt", true], ["config/secrets/prod.yml", true], ["secrets/manager.ts", true], [".secrets/x", true], ["secrets.json", true], ["secrets.yaml", true],
|
|
46
|
+
["id_rsa", true], ["id_rsa.pub", true], ["id_rsa_github", true], ["id_ed25519", true], ["id_ed25519.pub", true], ["id_ed25519_work", true], ["id_ecdsa", true],
|
|
47
|
+
["server.pem", true], ["private.key", true], ["release.keystore", true], ["debug.keystore", true], ["app.jks", true], ["cert.p12", true], ["vault.kdbx", true],
|
|
48
|
+
[".npmrc", true], ["pkg/.npmrc", true], [".pypirc", true], [".netrc", true], ["_netrc", true], [".pgpass", true], [".git-credentials", true],
|
|
49
|
+
[".docker/config.json", true], [".ssh/known_hosts", true], [".git/config", true], [".aws/config", true], [".gnupg/pubring.kbx", true],
|
|
50
|
+
["terraform.tfstate", true], ["prod.tfvars", true], ["service-account-prod.json", true], [".htpasswd", true],
|
|
51
|
+
// ---- blocked: Windows aliases and streams of the above
|
|
52
|
+
["CREDEN~1.JSO", true], ["CREDEN~1", true], ["ENV~1", true], ["KUBECO~1", true], [".env::$DATA", true], ["credentials.json:Zone.Identifier", true],
|
|
53
|
+
// ---- F3-07c review: credential files that no earlier rule named (blocked) and their look-alikes (served)
|
|
54
|
+
[".dockercfg", true], [".yarnrc", true], [".yarnrc.yml", true], [".pnpmrc", true], [".s3cfg", true], [".boto", true], [".terraformrc", true],
|
|
55
|
+
[".Renviron", true], ["auth.json", true], ["sub/auth.json", true], ["client_secret_123.json", true], ["firebase-adminsdk-abc.json", true],
|
|
56
|
+
["gha-creds-1234.json", true], ["local.settings.json", true], ["wp-config.php", true], ["application-prod.properties", true],
|
|
57
|
+
["application-prod.yml", true], ["application-prod.yaml", true], ["deploy_rsa", true], ["host_ed25519", true], ["ssh_host_rsa_key", true],
|
|
58
|
+
[".m2/settings.xml", true], [".m2/settings-security.xml", true], [".envrc/a.txt", true], [".env_backup/a.txt", true], [".env-prod/a.txt", true],
|
|
59
|
+
["env.production/a.txt", true], ["kubeconfig-prod/a.txt", true], ["ENVRC~1", true], ["ENVS~1/x", true],
|
|
60
|
+
["settings.xml", false], ["pom.xml", false], ["src/main/resources/application.yml", false], ["application-dev.yml", false],
|
|
61
|
+
["application-test.properties", false], ["auth.ts", false], ["author.json", false], ["auth.service.ts", false], ["wp-config-sample.php", false],
|
|
62
|
+
["rsa.ts", false], ["key_ed25519.ts", false], ["src/m2/settings.xml", false],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
test("deny list table (F3-07c): >= 80 names, served and blocked, each one on its own", () => {
|
|
66
|
+
assert.ok(FILES.length >= 80, `${FILES.length} rows`);
|
|
67
|
+
assert.ok(FILES.filter(([, s]) => !s).length >= 30 && FILES.filter(([, s]) => s).length >= 50, "both sides are well covered");
|
|
68
|
+
const wrong = FILES.filter(([p, s]) => isSensitivePath(p) !== s).map(([p, s]) => `${p} expected ${s ? "blocked" : "served"}`);
|
|
69
|
+
assert.deepEqual(wrong, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("deny list: a folder entry (dir) is judged as a folder: src/credentials is a folder of code, .credentials and secrets are not", () => {
|
|
73
|
+
for (const [p, sens] of [["credentials", false], ["src/credentials", false], ["Credentials", false], ["credential", false], ["kubeconfig", false], ["env", false],
|
|
74
|
+
[".credentials", true], ["secrets", true], [".secrets", true], [".git", true], [".ssh", true], [".aws", true], [".envs", true], [".env", true], ["x/.env", true],
|
|
75
|
+
["node_modules", false], ["src", false], ["docs", false], ["environment", false]]) {
|
|
76
|
+
assert.equal(isSensitivePath(p, [], { dir: true }), sens, `dir ${p}`);
|
|
77
|
+
}
|
|
78
|
+
// the same name as a FILE stays blocked: the guard never serves a file called `credentials`
|
|
79
|
+
assert.equal(isSensitivePath("src/credentials"), true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("deny list: code extensions are an allow list; data formats, scripts and backups never are", () => {
|
|
83
|
+
for (const e of ["ts", "java", "py", "go", "js", "md", "sql", "kt", "rb", "cs", "rs", "php", "vue", "svelte"]) assert.ok(CODE_EXTENSIONS.has(e), e);
|
|
84
|
+
for (const e of ["json", "yml", "yaml", "xml", "toml", "ini", "cfg", "conf", "csv", "txt", "properties", "db", "sqlite", "enc", "pem", "p12", "pfx", "kdbx", "tfvars", "env",
|
|
85
|
+
"sh", "bash", "ps1", "bat", "gradle", "bak", "old", "orig", "tmp", "swp", "map", "snap", "j2", "log", ""]) assert.equal(CODE_EXTENSIONS.has(e), false, e);
|
|
86
|
+
for (const e of ["json", "yml", "toml", "txt", "sh", "bak"]) assert.equal(isSensitivePath(`credentials.${e}`), true, e);
|
|
87
|
+
for (const e of ["ts", "java", "py", "go", "md"]) assert.equal(isSensitivePath(`credentials.${e}`), false, e);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("deny list: LOCAL_AGENT_CONTENT_DENY_GLOBS still adds to it (credentials.service.ts can be put back by an operator)", () => {
|
|
91
|
+
assert.equal(isSensitivePath("credentials.service.ts", ["credentials*"]), true);
|
|
92
|
+
assert.equal(isSensitivePath("credentials.service.ts", []), false);
|
|
93
|
+
assert.equal(isSensitivePath("src/x.ts", ["src/"]), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("deny list: the registry never mints an id for a blocked name, and mints for credentials.service.ts; a folder id for src/credentials", () => {
|
|
97
|
+
const reg = new ContentRefRegistry({ tenantId: "t1" });
|
|
98
|
+
const q = { workspaceId: "w", root: "/r" };
|
|
99
|
+
assert.ok(reg.mint({ ...q, kind: "file", rel: "src/credentials.service.ts" }));
|
|
100
|
+
for (const rel of ["credentials.json", ".envrc", "env.production", "secrets/x.ts", "credentials"]) assert.equal(reg.mint({ ...q, kind: "file", rel }), null, rel);
|
|
101
|
+
assert.ok(reg.mint({ ...q, kind: "file.list", rel: "src/credentials" }), "a folder named credentials may be listed");
|
|
102
|
+
assert.equal(reg.mint({ ...q, kind: "file.list", rel: "secrets" }), null);
|
|
103
|
+
assert.equal(reg.mint({ ...q, kind: "file.list", rel: ".credentials" }), null);
|
|
104
|
+
assert.ok(reg.mint({ ...q, kind: "file.list", rel: null }), "the root");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("deny list at read time: credentials.service.ts is served (masked by the DLP), credentials.json and a link to it are not", async () => {
|
|
108
|
+
const rig = await setup({ dlp: "enforce" });
|
|
109
|
+
try {
|
|
110
|
+
const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
111
|
+
write(rig.root(), "src/credentials.service.ts", `export const token = "${secret}";\nexport const ok = 1;\n`);
|
|
112
|
+
write(rig.root(), "credentials.json", `{"token":"${secret}"}`);
|
|
113
|
+
const good = rig.mint("file", "src/credentials.service.ts");
|
|
114
|
+
assert.ok(good, "an id exists for the code file");
|
|
115
|
+
const r = await rig.request({ ref: good });
|
|
116
|
+
assert.equal(r.status, "ok");
|
|
117
|
+
assert.equal(r.text.includes(secret), false, "the DLP is still the second layer");
|
|
118
|
+
assert.match(r.text, /export const ok = 1/);
|
|
119
|
+
assert.equal(rig.mint("file", "credentials.json"), null);
|
|
120
|
+
// swapped in after the id was minted: a harmless name that now links to credentials.json
|
|
121
|
+
write(rig.root(), "later.ts", "x");
|
|
122
|
+
const id = rig.mint("file", "later.ts");
|
|
123
|
+
fs.rmSync(path.join(rig.root(), "later.ts"));
|
|
124
|
+
fs.symlinkSync(path.join(rig.root(), "credentials.json"), path.join(rig.root(), "later.ts"));
|
|
125
|
+
assert.equal((await rig.request({ ref: id })).status, "forbidden");
|
|
126
|
+
assert.deepEqual(await resolveInside(rig.root(), "credentials.json"), { ok: false, status: "forbidden" });
|
|
127
|
+
assert.equal((await resolveInside(rig.root(), "src/credentials.service.ts")).ok, true);
|
|
128
|
+
} finally { await rig.stop(); }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("deny list: nothing about the new rules is a regex over the whole path (no catastrophic backtracking on a long name)", () => {
|
|
132
|
+
const long = "credentials".repeat(5000) + ".json";
|
|
133
|
+
const t = Date.now();
|
|
134
|
+
assert.equal(isSensitivePath(long.slice(0, 1000)), true);
|
|
135
|
+
assert.equal(isSensitivePath("a/".repeat(500) + "x.ts"), false);
|
|
136
|
+
assert.ok(Date.now() - t < 500);
|
|
137
|
+
void tmpDir;
|
|
138
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// F3-07c: `git diff` of a tracked symlink prints the TARGET of the link ("new file mode 120000", "+/abs/path"): an absolute path of this
|
|
2
|
+
// machine. Entries whose mode is 120000 on either side no longer reach the diff. Real repositories, real links.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { setup, write, git, initRepo, tmpDir } from "./helpers/content-rig.js";
|
|
8
|
+
import { rawPaths, dropSymlinkBlocks, worktreeDiff } from "../../src/la-protocol-v2/content-handlers/git-diff.js";
|
|
9
|
+
|
|
10
|
+
async function withRig(opts, body) {
|
|
11
|
+
const rig = await setup(opts);
|
|
12
|
+
try { await body(rig); } finally { await rig.stop(); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OUTSIDE = "/very/private/absolute/target/of-the-link";
|
|
16
|
+
|
|
17
|
+
test("diff: a tracked symlink that is retargeted, deleted, replaced by a file or created by replacing a file never prints its target; regular files still diff", async () => {
|
|
18
|
+
await withRig({}, async (rig) => {
|
|
19
|
+
const root = rig.root();
|
|
20
|
+
write(root, "regular.txt", "one\n");
|
|
21
|
+
write(root, "was-file.txt", "content\n");
|
|
22
|
+
write(root, "was-link-target.txt", "t\n");
|
|
23
|
+
fs.symlinkSync("/old/absolute/target-a", path.join(root, "link-retarget"));
|
|
24
|
+
fs.symlinkSync("/old/absolute/target-b", path.join(root, "link-deleted"));
|
|
25
|
+
fs.symlinkSync("/old/absolute/target-c", path.join(root, "link-becomes-file"));
|
|
26
|
+
initRepo(root);
|
|
27
|
+
// work tree changes
|
|
28
|
+
write(root, "regular.txt", "two\n");
|
|
29
|
+
fs.rmSync(path.join(root, "link-retarget")); fs.symlinkSync(OUTSIDE, path.join(root, "link-retarget"));
|
|
30
|
+
fs.rmSync(path.join(root, "link-deleted"));
|
|
31
|
+
fs.rmSync(path.join(root, "link-becomes-file")); write(root, "link-becomes-file", "now a file\n");
|
|
32
|
+
fs.rmSync(path.join(root, "was-file.txt")); fs.symlinkSync(OUTSIDE + "-2", path.join(root, "was-file.txt"));
|
|
33
|
+
// sanity: plain git DOES print the targets (this is what the guard hides)
|
|
34
|
+
const raw = git(root, "diff", "HEAD");
|
|
35
|
+
assert.match(raw, /new file mode 120000|deleted file mode 120000|120000/);
|
|
36
|
+
assert.ok(raw.includes(OUTSIDE) || raw.includes("/old/absolute/target"), "plain git shows the target");
|
|
37
|
+
const d = await rig.request({ kind: "diff", ref: "worktree" });
|
|
38
|
+
assert.equal(d.status, "ok");
|
|
39
|
+
assert.match(d.text, /regular\.txt/);
|
|
40
|
+
assert.match(d.text, /\+two/);
|
|
41
|
+
for (const leak of ["120000", OUTSIDE, "/old/absolute", "link-retarget", "link-deleted", "link-becomes-file", "was-file.txt"]) assert.equal(d.text.includes(leak), false, `leaked ${leak}`);
|
|
42
|
+
// an id for one path that is a link: empty diff, not the target
|
|
43
|
+
const one = rig.mint("diff", "link-retarget");
|
|
44
|
+
const o = await rig.request({ kind: "diff", ref: one });
|
|
45
|
+
assert.deepEqual([o.status, o.text], ["ok", ""]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("diff: a file turned into a link AFTER the file list was taken is still not printed (lstat net); the text net removes any block with mode 120000", async () => {
|
|
50
|
+
const root = tmpDir();
|
|
51
|
+
write(root, "a.txt", "1\n"); write(root, "b.txt", "1\n");
|
|
52
|
+
initRepo(root);
|
|
53
|
+
write(root, "a.txt", "2\n"); write(root, "b.txt", "2\n");
|
|
54
|
+
fs.rmSync(path.join(root, "b.txt")); fs.symlinkSync(OUTSIDE, path.join(root, "b.txt"));
|
|
55
|
+
const r = await worktreeDiff({ root, maxBytes: 1 << 20, maxFiles: 100, timeoutMs: 8000 });
|
|
56
|
+
assert.equal(r.ok, true);
|
|
57
|
+
assert.match(r.text, /a\.txt/);
|
|
58
|
+
assert.equal(r.text.includes("b.txt"), false);
|
|
59
|
+
assert.equal(r.text.includes(OUTSIDE), false);
|
|
60
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
61
|
+
|
|
62
|
+
const fake = [
|
|
63
|
+
"diff --git a/l b/l\nnew file mode 120000\nindex 0000000..e69de29\n--- /dev/null\n+++ b/l\n@@ -0,0 +1 @@\n+/etc/hostname\n\\n",
|
|
64
|
+
"diff --git a/m b/m\ndeleted file mode 120000\nindex e69de29..0000000\n--- a/m\n+++ /dev/null\n@@ -1 +0,0 @@\n-/etc/hosts\n",
|
|
65
|
+
"diff --git a/n b/n\nold mode 100644\nnew mode 120000\n",
|
|
66
|
+
"diff --git a/o b/o\nindex 1111111..2222222 120000\n--- a/o\n+++ b/o\n@@ -1 +1 @@\n-/a\n+/b\n",
|
|
67
|
+
"diff --git a/ok b/ok\nindex 1111111..2222222 100644\n--- a/ok\n+++ b/ok\n@@ -1 +1 @@\n-mode 120000 is just text here\n+new file mode 120000 neither\n",
|
|
68
|
+
];
|
|
69
|
+
const out = dropSymlinkBlocks(fake.join(""));
|
|
70
|
+
assert.equal(out, fake[4], "only the regular file's block survives, including a body that talks about 120000");
|
|
71
|
+
assert.equal(dropSymlinkBlocks(fake[4]), fake[4]);
|
|
72
|
+
assert.equal(dropSymlinkBlocks(""), "");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("diff: git diff --raw -z parsing: modes on either side, paths with spaces/newlines/unicode, and no path is ever split", () => {
|
|
76
|
+
const out = ":100644 100644 aaa bbb M\0src/a b.txt\0:100644 120000 aaa 000 T\0link\0:120000 000000 aaa 000 D\0gone\0:000000 100644 000 bbb A\0não\nsplit.txt\0:100755 100755 aaa bbb M\0x\0";
|
|
77
|
+
assert.deepEqual(rawPaths(out), [
|
|
78
|
+
{ path: "src/a b.txt", symlink: false }, { path: "link", symlink: true }, { path: "gone", symlink: true },
|
|
79
|
+
{ path: "não\nsplit.txt", symlink: false }, { path: "x", symlink: false },
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(rawPaths(""), []);
|
|
82
|
+
assert.deepEqual(rawPaths(":100644 100644 a b M"), [], "a truncated entry gives nothing");
|
|
83
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// F3-07c: `file.list`, the producer of the opaque ids. Real V2Session, real handler, real folders on disk; the control plane is the
|
|
2
|
+
// in-memory ReferenceCp. Against the REAL gateway: tools/interop/LaV2NodeContentRelayE2ETest.java (F3-07c section).
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { ReferenceCp, TENANT, AGENT, waitFor } from "./helpers/reference-cp.js";
|
|
9
|
+
import { signingFor } from "./helpers/la-rig.js";
|
|
10
|
+
import { setup, write, tmpDir } from "./helpers/content-rig.js";
|
|
11
|
+
import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
|
|
12
|
+
import { loadContract } from "../../src/la-protocol-v2/contract.js";
|
|
13
|
+
import { listFolder } from "../../src/la-protocol-v2/content-handlers/file-list.js";
|
|
14
|
+
import { ContentRefRegistry } from "../../src/la-protocol-v2/content-handlers/ref-registry.js";
|
|
15
|
+
import { resolveContentHandlersConfig } from "../../src/la-protocol-v2/content-handlers/config.js";
|
|
16
|
+
|
|
17
|
+
const ID43 = /^[A-Za-z0-9_-]{43}$/;
|
|
18
|
+
const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
|
|
19
|
+
const contract = loadContract();
|
|
20
|
+
|
|
21
|
+
async function withRig(opts, body) {
|
|
22
|
+
const rig = await setup(opts);
|
|
23
|
+
try { await body(rig); } finally { await rig.stop(); }
|
|
24
|
+
}
|
|
25
|
+
const list = (rig, ref = "root", extra = {}) => rig.request({ kind: "file.list", ref, ...extra });
|
|
26
|
+
const body = (r) => JSON.parse(r.text);
|
|
27
|
+
const names = (r) => body(r).entries.map((e) => e.name);
|
|
28
|
+
|
|
29
|
+
/** A workspace with everything the listing must and must not show. */
|
|
30
|
+
function seed(root) {
|
|
31
|
+
write(root, "README.md", `# readme\ntoken ${GH}\n`);
|
|
32
|
+
write(root, "a.txt", "aaa");
|
|
33
|
+
write(root, "credentials.service.ts", "export const x = 1;\n");
|
|
34
|
+
write(root, "src/main.ts", "export {};\n");
|
|
35
|
+
write(root, "src/credentials/index.ts", "export {};\n");
|
|
36
|
+
// never listed
|
|
37
|
+
write(root, ".env", "A=1");
|
|
38
|
+
write(root, "credentials.json", "{}");
|
|
39
|
+
write(root, ".envrc", "export A=1");
|
|
40
|
+
write(root, "secrets/api.txt", "x");
|
|
41
|
+
write(root, ".git/config", "[core]");
|
|
42
|
+
write(root, "id_rsa", "x");
|
|
43
|
+
write(root, "server.pem", "x");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("file.list: the root lists what may be served, dirs first then names by bytes; credentials.service.ts is there, .env and credentials.json are not (not even their names)", async () => {
|
|
47
|
+
await withRig({}, async (rig) => {
|
|
48
|
+
seed(rig.root());
|
|
49
|
+
const r = await list(rig);
|
|
50
|
+
assert.deepEqual([r.status, r.contentType], ["ok", "application/json"]);
|
|
51
|
+
assert.deepEqual(names(r), ["src", "README.md", "a.txt", "credentials.service.ts"]);
|
|
52
|
+
for (const never of [".env", "credentials.json", ".envrc", "secrets", ".git", "id_rsa", "server.pem"]) assert.equal(r.text.includes(never), false, never);
|
|
53
|
+
const b = body(r);
|
|
54
|
+
assert.equal(contract.validateBody("file.list.body.schema.json", b), true, "the body is the contract's body");
|
|
55
|
+
assert.deepEqual(b.entries.map((e) => e.type), ["dir", "file", "file", "file"]);
|
|
56
|
+
assert.equal(b.nextRef, undefined);
|
|
57
|
+
for (const e of b.entries) assert.match(e.id, ID43);
|
|
58
|
+
assert.equal(new Set(b.entries.map((e) => e.id)).size, 4, "one id per entry");
|
|
59
|
+
const a = b.entries.find((e) => e.name === "a.txt");
|
|
60
|
+
assert.equal(a.size, 3);
|
|
61
|
+
assert.ok(Math.abs(Date.parse(a.mtime) - fs.statSync(path.join(rig.root(), "a.txt")).mtimeMs) < 2000);
|
|
62
|
+
assert.equal(b.entries[0].size, 0, "a folder has size 0");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("file.list: an id of the listing opens the file (masked by the DLP) and the folder id lists the folder; nothing outside the listing can be asked for", async () => {
|
|
67
|
+
await withRig({}, async (rig) => {
|
|
68
|
+
seed(rig.root());
|
|
69
|
+
const b = body(await list(rig));
|
|
70
|
+
const readme = b.entries.find((e) => e.name === "README.md");
|
|
71
|
+
const f = await rig.request({ kind: "file", ref: readme.id });
|
|
72
|
+
assert.equal(f.status, "ok");
|
|
73
|
+
assert.match(f.text, /# readme/);
|
|
74
|
+
assert.equal(f.text.includes(GH), false, "DLP second layer on the content");
|
|
75
|
+
const src = b.entries.find((e) => e.name === "src");
|
|
76
|
+
const sub = await list(rig, src.id);
|
|
77
|
+
assert.deepEqual(names(sub), ["credentials", "main.ts"]);
|
|
78
|
+
const cred = body(sub).entries.find((e) => e.name === "credentials");
|
|
79
|
+
assert.deepEqual(names(await list(rig, cred.id)), ["index.ts"]);
|
|
80
|
+
// kinds do not mix
|
|
81
|
+
assert.equal((await list(rig, readme.id)).status, "not_found", "a file id is not a folder id");
|
|
82
|
+
assert.equal((await rig.request({ kind: "file", ref: src.id })).status, "not_found", "a folder id is not a file id");
|
|
83
|
+
assert.equal((await rig.request({ kind: "diff", ref: src.id })).status, "not_found");
|
|
84
|
+
// other workspace / unknown / path-like
|
|
85
|
+
assert.equal((await list(rig, src.id, { workspaceId: "ws-2" })).status, "not_found");
|
|
86
|
+
assert.equal((await list(rig, "root", { workspaceId: "ws-nope" })).status, "not_found");
|
|
87
|
+
for (const ref of ["src", "../x", "/etc", "A".repeat(43), "cr1_" + "A".repeat(22), "ROOT", "root/", "%2e%2e", ".", "worktree"]) {
|
|
88
|
+
assert.equal((await list(rig, ref)).status, "not_found", ref);
|
|
89
|
+
}
|
|
90
|
+
// the ids of a file the listing did not show cannot be had: there is none for .env
|
|
91
|
+
assert.equal(rig.registry.live().filter((e) => e.kind === "file").length, 5, "exactly the files shown so far: README.md, a.txt, credentials.service.ts, main.ts, index.ts");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("file.list: dlp + blocked entries: a name with a secret is masked (the id still works), a blocked class withholds the entry and mints nothing", async () => {
|
|
96
|
+
await withRig({}, async (rig) => {
|
|
97
|
+
write(rig.root(), `${GH}.txt`, "named after a token");
|
|
98
|
+
write(rig.root(), "plain.txt", "p");
|
|
99
|
+
const r = await list(rig);
|
|
100
|
+
assert.equal(r.text.includes(GH), false);
|
|
101
|
+
const b = body(r);
|
|
102
|
+
const masked = b.entries.find((e) => e.name.includes("REDACTED"));
|
|
103
|
+
assert.ok(masked, "the name was masked: " + names(r));
|
|
104
|
+
assert.equal(contract.validateBody("file.list.body.schema.json", b), true, "a masked name keeps the body valid (the ids are not masked)");
|
|
105
|
+
assert.equal((await rig.request({ kind: "file", ref: masked.id })).text, "named after a token");
|
|
106
|
+
});
|
|
107
|
+
await withRig({ env: { LOCAL_AGENT_DLP_BLOCK_CLASSES: "vcs-token" } }, async (rig) => {
|
|
108
|
+
write(rig.root(), `${GH}.txt`, "x");
|
|
109
|
+
write(rig.root(), "plain.txt", "p");
|
|
110
|
+
const r = await list(rig);
|
|
111
|
+
assert.deepEqual(names(r), ["plain.txt"]);
|
|
112
|
+
assert.equal(rig.registry.size, 1, "no id was minted for the withheld entry");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("file.list: only with DLP enforce (nothing is read, nothing minted otherwise); a range makes no sense; the flag-off path is the old one", async () => {
|
|
117
|
+
for (const dlp of ["off", "warn"]) {
|
|
118
|
+
await withRig({ dlp }, async (rig) => {
|
|
119
|
+
seed(rig.root());
|
|
120
|
+
const r = await list(rig);
|
|
121
|
+
assert.deepEqual([r.status, r.errorCode, r.body.length], ["error", "protocol_unsupported", 0], dlp);
|
|
122
|
+
assert.equal(rig.registry.size, 0);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
await withRig({}, async (rig) => {
|
|
126
|
+
seed(rig.root());
|
|
127
|
+
const r = await list(rig, "root", { range: { offset: 0, length: 10 } });
|
|
128
|
+
assert.deepEqual([r.status, r.errorCode], ["error", "protocol_unsupported"]);
|
|
129
|
+
assert.equal(rig.registry.size, 0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("file.list: paging by count: limit N gives N entries and a nextRef (a folder id that remembers the position); the pages add up to the folder, in order, once each", async () => {
|
|
134
|
+
await withRig({}, async (rig) => {
|
|
135
|
+
for (let i = 0; i < 25; i++) write(rig.root(), `f${String(i).padStart(2, "0")}.txt`, "x");
|
|
136
|
+
for (let i = 0; i < 3; i++) fs.mkdirSync(path.join(rig.root(), `d${i}`));
|
|
137
|
+
const all = [];
|
|
138
|
+
let ref = "root";
|
|
139
|
+
let pages = 0;
|
|
140
|
+
const refs = new Set();
|
|
141
|
+
for (; ref && pages < 10; pages++) {
|
|
142
|
+
const r = await list(rig, ref, { limit: 10 });
|
|
143
|
+
assert.equal(r.status, "ok");
|
|
144
|
+
const b = body(r);
|
|
145
|
+
assert.ok(b.entries.length <= 10);
|
|
146
|
+
all.push(...b.entries.map((e) => e.name));
|
|
147
|
+
if (b.nextRef) { assert.match(b.nextRef, ID43); assert.equal(refs.has(b.nextRef), false); refs.add(b.nextRef); }
|
|
148
|
+
else assert.equal(pages, 2, "the third page is the last");
|
|
149
|
+
ref = b.nextRef;
|
|
150
|
+
}
|
|
151
|
+
assert.equal(pages, 3);
|
|
152
|
+
assert.deepEqual(all, [...["d0", "d1", "d2"], ...Array.from({ length: 25 }, (_, i) => `f${String(i).padStart(2, "0")}.txt`)]);
|
|
153
|
+
// the nextRef belongs to the workspace and to the listing kind
|
|
154
|
+
const first = body(await list(rig, "root", { limit: 10 }));
|
|
155
|
+
assert.equal((await list(rig, first.nextRef, { workspaceId: "ws-2" })).status, "not_found");
|
|
156
|
+
assert.equal((await rig.request({ kind: "file", ref: first.nextRef })).status, "not_found");
|
|
157
|
+
// a continuation inside a sub folder stays in that folder
|
|
158
|
+
write(rig.root(), "sub/a.txt", "1"); write(rig.root(), "sub/b.txt", "1"); write(rig.root(), "sub/c.txt", "1");
|
|
159
|
+
const sub = body(await list(rig, "root", { limit: 1000 })).entries.find((e) => e.name === "sub");
|
|
160
|
+
const p1 = body(await list(rig, sub.id, { limit: 2 }));
|
|
161
|
+
assert.deepEqual(p1.entries.map((e) => e.name), ["a.txt", "b.txt"]);
|
|
162
|
+
assert.deepEqual(names(await list(rig, p1.nextRef, { limit: 2 })), ["c.txt"]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("file.list: paging by size: 255-character names do not fit 1000 to a page; every page is within the byte budget and the pages still add up", async () => {
|
|
167
|
+
await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_BYTES: "8192" } }, async (rig) => {
|
|
168
|
+
const N = 40;
|
|
169
|
+
const long = (i) => `${String(i).padStart(3, "0")}-${"n".repeat(240)}.txt`;
|
|
170
|
+
for (let i = 0; i < N; i++) write(rig.root(), long(i), "x");
|
|
171
|
+
const all = [];
|
|
172
|
+
let ref = "root";
|
|
173
|
+
let pages = 0;
|
|
174
|
+
while (ref && pages < 40) {
|
|
175
|
+
const r = await list(rig, ref, { limit: 1000 });
|
|
176
|
+
assert.equal(r.status, "ok", r.errorCode);
|
|
177
|
+
assert.ok(r.body.length <= 8192, `page ${pages}: ${r.body.length} bytes`);
|
|
178
|
+
assert.ok(body(r).entries.length >= 1);
|
|
179
|
+
all.push(...names(r));
|
|
180
|
+
ref = body(r).nextRef;
|
|
181
|
+
pages++;
|
|
182
|
+
}
|
|
183
|
+
assert.ok(pages >= 3, "several pages");
|
|
184
|
+
assert.deepEqual(all, Array.from({ length: N }, (_, i) => long(i)));
|
|
185
|
+
assert.equal(rig.registry.size, N + (pages - 1), "ids exist only for what was shown (+ one continuation per page but the last): an entry that did not fit left no id behind");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("file.list: a folder with more than LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES entries is payload_too_large (never 'the first N the disk returned'); the limit itself is fine", async () => {
|
|
190
|
+
await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: "100" } }, async (rig) => {
|
|
191
|
+
for (let i = 0; i < 100; i++) write(rig.root(), `big/f${i}.txt`, "x");
|
|
192
|
+
const big = body(await list(rig)).entries.find((e) => e.name === "big");
|
|
193
|
+
assert.equal(body(await list(rig, big.id, { limit: 1000 })).entries.length, 100);
|
|
194
|
+
write(rig.root(), "big/one-more.txt", "x");
|
|
195
|
+
const r = await list(rig, big.id);
|
|
196
|
+
assert.deepEqual([r.status, r.errorCode, r.body.length], ["error", "payload_too_large", 0]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("file.list: links, hard links, devices and odd names: a link that leaves the root, a link to a secret, a hard link, a fifo and a name with a newline are simply not there", async () => {
|
|
201
|
+
await withRig({}, async (rig) => {
|
|
202
|
+
const root = rig.root();
|
|
203
|
+
const out = tmpDir("f307c-out-");
|
|
204
|
+
rig.dirs.push(out);
|
|
205
|
+
write(out, "outside.txt", "outside secret");
|
|
206
|
+
write(out, "dir/inner.txt", "x");
|
|
207
|
+
write(root, "ok.txt", "ok");
|
|
208
|
+
write(root, "real/inner.txt", "i");
|
|
209
|
+
write(root, ".env", "A=1");
|
|
210
|
+
write(root, "secrets/x.txt", "s");
|
|
211
|
+
fs.symlinkSync(path.join(out, "outside.txt"), path.join(root, "link-out.txt"));
|
|
212
|
+
fs.symlinkSync(path.join(out, "dir"), path.join(root, "link-outdir"));
|
|
213
|
+
fs.symlinkSync(path.join(root, ".env"), path.join(root, "innocent.txt"));
|
|
214
|
+
fs.symlinkSync(path.join(root, "secrets"), path.join(root, "innocent-dir"));
|
|
215
|
+
fs.symlinkSync(path.join(root, "ok.txt"), path.join(root, "link-in.txt"));
|
|
216
|
+
fs.symlinkSync(path.join(root, "real"), path.join(root, "link-indir"));
|
|
217
|
+
fs.symlinkSync(path.join(root, "missing"), path.join(root, "dangling"));
|
|
218
|
+
fs.symlinkSync("../outside.txt", path.join(root, "rel-out.txt"));
|
|
219
|
+
fs.symlinkSync(root, path.join(root, "loop")); // a link to the root itself
|
|
220
|
+
try { fs.linkSync(path.join(root, "ok.txt"), path.join(root, "hard.txt")); } catch { /* no hard links here */ }
|
|
221
|
+
try { execFileSync("mkfifo", [path.join(root, "pipe")]); } catch { /* no mkfifo */ }
|
|
222
|
+
try { write(root, "bad\nname.txt", "x"); } catch { /* a file system that refuses it */ }
|
|
223
|
+
try { write(root, "repl\ufffdchar.txt", "x"); } catch { /* idem: a name with U+FFFD is what invalid UTF-8 looks like after decoding, so it is never listed */ }
|
|
224
|
+
const r = await list(rig);
|
|
225
|
+
assert.equal(r.status, "ok");
|
|
226
|
+
const n = names(r);
|
|
227
|
+
// ok.txt itself is hard-linked now (hard.txt is its second name): both are left out by the default hard link rule
|
|
228
|
+
const hard = fs.statSync(path.join(root, "ok.txt")).nlink > 1;
|
|
229
|
+
assert.deepEqual(n.filter((x) => !["ok.txt", "link-in.txt"].includes(x)), ["link-indir", "real"], n.join(","));
|
|
230
|
+
assert.equal(n.includes("ok.txt"), !hard);
|
|
231
|
+
assert.equal(n.includes("link-in.txt"), !hard, "a link to a hard-linked file is a name of it: same rule");
|
|
232
|
+
for (const never of ["link-out", "link-outdir", "innocent", "dangling", "rel-out", "pipe", "bad", "repl", "loop", ".env", "secrets", "hard.txt"]) assert.equal(r.text.includes(never), false, never);
|
|
233
|
+
// a link to a folder inside the root lists as a folder, and the ids it hands out are for the real folder
|
|
234
|
+
const li = body(r).entries.find((e) => e.name === "link-indir");
|
|
235
|
+
assert.equal(li.type, "dir");
|
|
236
|
+
assert.deepEqual(names(await list(rig, li.id)), ["inner.txt"]);
|
|
237
|
+
// a folder swapped for a link out of the root / to a secret after its id was minted is forbidden at list time
|
|
238
|
+
const real = body(r).entries.find((e) => e.name === "real");
|
|
239
|
+
fs.rmSync(path.join(root, "real"), { recursive: true });
|
|
240
|
+
fs.symlinkSync(path.join(out, "dir"), path.join(root, "real"));
|
|
241
|
+
assert.equal((await list(rig, real.id)).status, "forbidden");
|
|
242
|
+
fs.rmSync(path.join(root, "real"));
|
|
243
|
+
fs.symlinkSync(path.join(root, "secrets"), path.join(root, "real"));
|
|
244
|
+
assert.equal((await list(rig, real.id)).status, "forbidden");
|
|
245
|
+
assert.equal(r.text.includes(out), false);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("file.list: the hard link rule can be lifted by LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS (as for file)", async () => {
|
|
250
|
+
await withRig({ env: { LOCAL_AGENT_CONTENT_ALLOW_HARDLINKS: "true" } }, async (rig) => {
|
|
251
|
+
write(rig.root(), "a.txt", "x");
|
|
252
|
+
try { fs.linkSync(path.join(rig.root(), "a.txt"), path.join(rig.root(), "b.txt")); } catch { return; }
|
|
253
|
+
assert.deepEqual(names(await list(rig)), ["a.txt", "b.txt"]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("file.list: unicode, spaces and a root reached through a link; an empty folder is an empty page; a folder that is not there is not_found", async () => {
|
|
258
|
+
await withRig({}, async (rig) => {
|
|
259
|
+
write(rig.root(), "ação final.txt", "x"); write(rig.root(), "日本語.md", "x"); write(rig.root(), "with space/a b.txt", "x"); fs.mkdirSync(path.join(rig.root(), "empty"));
|
|
260
|
+
const b = body(await list(rig));
|
|
261
|
+
assert.deepEqual(b.entries.map((e) => e.name), ["empty", "with space", "ação final.txt", "日本語.md"]);
|
|
262
|
+
const empty = b.entries.find((e) => e.name === "empty");
|
|
263
|
+
const e = await list(rig, empty.id);
|
|
264
|
+
assert.deepEqual([e.status, body(e)], ["ok", { entries: [] }]);
|
|
265
|
+
fs.rmdirSync(path.join(rig.root(), "empty"));
|
|
266
|
+
assert.equal((await list(rig, empty.id)).status, "not_found");
|
|
267
|
+
});
|
|
268
|
+
const base = tmpDir(); const real = path.join(base, "real"); const link = path.join(base, "link");
|
|
269
|
+
fs.mkdirSync(real); write(real, "x.txt", "x"); fs.symlinkSync(real, link);
|
|
270
|
+
await withRig({ workspaces: { "ws-1": { root: link } } }, async (rig) => {
|
|
271
|
+
assert.deepEqual(names(await list(rig)), ["x.txt"]);
|
|
272
|
+
});
|
|
273
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("file.list: nothing but codes in errors, audit and logs: no name, no path and no id of a listing is ever in them", async () => {
|
|
277
|
+
await withRig({}, async (rig) => {
|
|
278
|
+
seed(rig.root());
|
|
279
|
+
write(rig.root(), "very-private-customer-name.txt", "x");
|
|
280
|
+
const r = await list(rig);
|
|
281
|
+
const ids = body(r).entries.map((e) => e.id);
|
|
282
|
+
await list(rig, "A".repeat(43)); await list(rig, ids[0]); await list(rig, "root", { workspaceId: "ws-nope" });
|
|
283
|
+
const noise = JSON.stringify([rig.handler.audit.tail(1000), rig.logs]);
|
|
284
|
+
for (const leak of ["very-private-customer-name", "README", "credentials", "main.ts", rig.root(), path.basename(rig.root()), ...ids, GH]) assert.equal(noise.includes(leak), false, `leaked ${leak}`);
|
|
285
|
+
const rec = rig.handler.audit.tail(1000).find((x) => x.kind === "file.list" && x.status === "ok");
|
|
286
|
+
assert.ok(rec && rec.bytes > 0 && rec.chunks >= 1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("file.list: a folder of 3000 entries gives a page of `limit`, and the slot and the memory reserved for the request come back", async () => {
|
|
291
|
+
await withRig({ env: { LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: "20000" } }, async (rig) => {
|
|
292
|
+
for (let i = 0; i < 3000; i++) fs.writeFileSync(path.join(rig.root(), `f${i}.txt`), "x");
|
|
293
|
+
const r = await list(rig, "root", { limit: 1000 });
|
|
294
|
+
assert.equal(r.status, "ok");
|
|
295
|
+
assert.equal(body(r).entries.length, 1000);
|
|
296
|
+
assert.equal(rig.handler.inflight, 0);
|
|
297
|
+
assert.equal(rig.handler.reservedBytes, 0);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("file.list: a cursor belongs to a folder id only (a file id with a cursor, or a bad cursor, is refused by the registry); listFolder stops when the request is aborted", async () => {
|
|
302
|
+
const reg = new ContentRefRegistry({ tenantId: TENANT });
|
|
303
|
+
const q = { workspaceId: "w", root: "/r" };
|
|
304
|
+
assert.equal(reg.mint({ ...q, kind: "file", rel: "a.txt", after: { dir: false, name: "a" } }), null);
|
|
305
|
+
assert.equal(reg.mint({ ...q, kind: "diff", rel: null, after: { dir: false, name: "a" } }), null);
|
|
306
|
+
for (const bad of ["x", 1, {}, { name: "" }, { name: "x".repeat(2000) }]) assert.equal(reg.mint({ ...q, kind: "file.list", rel: null, after: bad }), null, JSON.stringify(bad).slice(0, 20));
|
|
307
|
+
assert.ok(reg.mint({ ...q, kind: "file.list", rel: null, after: { dir: true, name: "d" } }));
|
|
308
|
+
const root = tmpDir();
|
|
309
|
+
write(root, ".env", "A=1"); // nothing listable: only the loop that reads the folder can notice the abort
|
|
310
|
+
const o = { root, limit: 10, maxBytes: 1 << 20, maxDirEntries: 1000, mint: () => null, maskName: (n) => ({ name: n, blocked: false, denied: false, total: 0, redactions: {} }) };
|
|
311
|
+
assert.deepEqual(await listFolder({ ...o }), { ok: true, body: Buffer.from('{"entries":[]}'), entries: 0, dlp: { total: 0, redactions: {} } });
|
|
312
|
+
assert.deepEqual(await listFolder({ ...o, signal: AbortSignal.abort() }), { ok: false, status: "error", errorCode: "agent_offline" });
|
|
313
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("config (F3-07c): LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES is clamped to 100..200000, a bad value is the default (20000), never unlimited", () => {
|
|
317
|
+
const c = (v) => resolveContentHandlersConfig({ LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_CONTENT_MAX_DIR_ENTRIES: v }, () => {}).maxDirEntries;
|
|
318
|
+
assert.deepEqual([c(undefined), c("abc"), c("99"), c("200001"), c("999999999999"), c("-5"), c("100"), c("200000"), c("5000")], [20_000, 20_000, 20_000, 20_000, 20_000, 20_000, 100, 200_000, 5_000]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ------------------------------------------------------------------------------------------ the real wiring: content.index
|
|
322
|
+
|
|
323
|
+
async function linkFor(cp, { env, root }) {
|
|
324
|
+
const logs = [];
|
|
325
|
+
const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m) };
|
|
326
|
+
const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
|
|
327
|
+
const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager,
|
|
328
|
+
getWorkspaceRoot: () => root, log, env: { LOCAL_AGENT_CONTENT_HANDLERS: "true", LOCAL_AGENT_DLP_MODE: "enforce", ...env } });
|
|
329
|
+
const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
|
|
330
|
+
cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
|
|
331
|
+
link.session.attach(transport);
|
|
332
|
+
await waitFor(() => link.session.state === "ready");
|
|
333
|
+
return { link, logs };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test("wiring: a listing mints through mintRef, so every id it hands out is announced with content.index ({kind, ref, size}: no names), and the register announces content.file.list", async () => {
|
|
337
|
+
const root = tmpDir(); seed(root); write(root, "very-private-customer-name.txt", "12345");
|
|
338
|
+
const cp = new ReferenceCp();
|
|
339
|
+
const { link, logs } = await linkFor(cp, { root, env: { LOCAL_AGENT_CONTENT_INDEX_BATCH_MS: "0" } });
|
|
340
|
+
assert.ok(cp.registers[0].capabilities.includes("content.file.list"));
|
|
341
|
+
const requestId = (await import("./helpers/reference-cp.js")).uuid();
|
|
342
|
+
cp.sendData("content.request", { kind: "file.list", ref: "root", actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
|
|
343
|
+
await waitFor(() => cp.contentRequests.get(requestId)?.done);
|
|
344
|
+
const b = JSON.parse(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString("utf8"));
|
|
345
|
+
assert.equal(b.entries.length, 5);
|
|
346
|
+
await waitFor(() => cp.received.filter((f) => f.type === "content.index").flatMap((f) => f.payload.entries).length === 5);
|
|
347
|
+
const idx = cp.received.filter((f) => f.type === "content.index").flatMap((f) => f.payload.entries);
|
|
348
|
+
const byRef = new Map(idx.map((e) => [e.ref, e]));
|
|
349
|
+
for (const e of b.entries) {
|
|
350
|
+
const i = byRef.get(e.id);
|
|
351
|
+
assert.ok(i, "announced: " + e.name.length);
|
|
352
|
+
assert.deepEqual(Object.keys(i).sort(), ["kind", "ref", "size"]);
|
|
353
|
+
assert.equal(i.kind, e.type === "dir" ? "file.list" : "file");
|
|
354
|
+
assert.equal(i.size, e.size);
|
|
355
|
+
}
|
|
356
|
+
const wire = JSON.stringify(cp.received.filter((f) => f.type === "content.index"));
|
|
357
|
+
for (const leak of ["very-private-customer-name", "README", "main.ts", root, path.basename(root)]) assert.equal(wire.includes(leak), false, `leaked ${leak}`);
|
|
358
|
+
assert.equal(logs.join("\n").includes("very-private"), false);
|
|
359
|
+
assert.deepEqual(cp.errorsSent, []);
|
|
360
|
+
link.close();
|
|
361
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("wiring: the nextRef of the real link works (the continuation is minted through mintRef with its cursor): page 2 continues where page 1 stopped", async () => {
|
|
365
|
+
const root = tmpDir();
|
|
366
|
+
for (const n of ["a", "b", "c", "d", "e"]) write(root, `${n}.txt`, n);
|
|
367
|
+
const cp = new ReferenceCp();
|
|
368
|
+
const { link } = await linkFor(cp, { root, env: {} });
|
|
369
|
+
const { uuid } = await import("./helpers/reference-cp.js");
|
|
370
|
+
const ask = async (ref, limit) => {
|
|
371
|
+
const requestId = uuid();
|
|
372
|
+
cp.sendData("content.request", { kind: "file.list", ref, limit, actor: { userId: "u-1", role: "member" } }, { workspaceId: "ws-1", requestId });
|
|
373
|
+
await waitFor(() => cp.contentRequests.get(requestId)?.done);
|
|
374
|
+
return JSON.parse(Buffer.concat(cp.contentRequests.get(requestId).chunks).toString("utf8"));
|
|
375
|
+
};
|
|
376
|
+
const p1 = await ask("root", 2);
|
|
377
|
+
assert.deepEqual(p1.entries.map((e) => e.name), ["a.txt", "b.txt"]);
|
|
378
|
+
assert.match(p1.nextRef, ID43);
|
|
379
|
+
const p2 = await ask(p1.nextRef, 2);
|
|
380
|
+
assert.deepEqual(p2.entries.map((e) => e.name), ["c.txt", "d.txt"]);
|
|
381
|
+
const p3 = await ask(p2.nextRef, 2);
|
|
382
|
+
assert.deepEqual([p3.entries.map((e) => e.name), p3.nextRef], [["e.txt"], undefined]);
|
|
383
|
+
link.close();
|
|
384
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
385
|
+
});
|