Skip to content

Commit 8042aa8

Browse files
authored
Merge pull request #1472 from DeusData/feat/workspace-grants-and-manifest
feat: declare indexing roots from the CLI; size-safe compression; prune the cache from discovery
2 parents 7865b87 + bb4fca5 commit 8042aa8

13 files changed

Lines changed: 1184 additions & 688 deletions

File tree

‎internal/cbm/zstd_store.c‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
#include <stddef.h>
88
#include <stdint.h>
99

10-
int cbm_zstd_compress(const char *src, int srcLen, char *dst, int dstCap, int level) {
11-
size_t rc = ZSTD_compress(dst, (size_t)dstCap, src, (size_t)srcLen, level);
10+
int64_t cbm_zstd_compress(const char *src, size_t srcLen, char *dst, size_t dstCap, int level) {
11+
size_t rc = ZSTD_compress(dst, dstCap, src, srcLen, level);
1212
if (ZSTD_isError(rc)) {
1313
return 0;
1414
}
15-
return (int)rc;
15+
return (int64_t)rc;
1616
}
1717

1818
int64_t cbm_zstd_decompress(const char *src, size_t srcLen, char *dst, size_t dstCap) {
@@ -31,6 +31,6 @@ size_t cbm_zstd_frame_content_size(const char *src, size_t srcLen) {
3131
return (size_t)n;
3232
}
3333

34-
size_t cbm_zstd_compress_bound(int inputSize) {
35-
return ZSTD_compressBound((size_t)inputSize);
34+
size_t cbm_zstd_compress_bound(size_t inputSize) {
35+
return ZSTD_compressBound(inputSize);
3636
}

‎internal/cbm/zstd_store.h‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
#include <stdint.h>
66

77
// Zstd compression at specified level (1=fast .. 22=best).
8-
// Returns compressed size on success, 0 on error.
9-
int cbm_zstd_compress(const char *src, int srcLen, char *dst, int dstCap, int level);
8+
// srcLen/dstCap are size_t for the same reason decompression's are: a >2 GiB
9+
// database would otherwise wrap through int and hand the encoder a capacity that
10+
// disagrees with the real buffer. Returns compressed size on success, 0 on error.
11+
int64_t cbm_zstd_compress(const char *src, size_t srcLen, char *dst, size_t dstCap, int level);
1012

1113
// Zstd decompression. srcLen/dstCap are size_t so a >2 GiB destination capacity
1214
// is never truncated (a large DB artifact, or a crafted one, would otherwise
@@ -20,6 +22,6 @@ int64_t cbm_zstd_decompress(const char *src, size_t srcLen, char *dst, size_t ds
2022
size_t cbm_zstd_frame_content_size(const char *src, size_t srcLen);
2123

2224
// Maximum compressed size bound for given input size.
23-
size_t cbm_zstd_compress_bound(int inputSize);
25+
size_t cbm_zstd_compress_bound(size_t inputSize);
2426

2527
#endif // CBM_ZSTD_STORE_H

‎src/daemon/bootstrap.c‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[])
162162
"install",
163163
"uninstall",
164164
"update",
165+
/* allow-root writes one line of user-level config and reads nothing from a
166+
* project, so it needs no daemon. Listed here rather than routed through
167+
* the daemon so enrolling a root cannot depend on daemon state. */
168+
"allow-root",
165169
};
166170
/* Stop at the first top-level mode token. Tool names, flag values, and JSON
167171
* following `cli` are opaque user input: a search query named "install"

‎src/discover/discover.c‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
#include "foundation/constants.h"
1515
#include "foundation/compat_fs.h"
16+
#include "foundation/workspace.h"
1617
#include "foundation/platform.h"
1718
#ifdef _WIN32
1819
#include "foundation/win_utf8.h"
@@ -562,6 +563,43 @@ static bool is_safety_core_dir(const char *name) {
562563
}
563564

564565
/* Check if a directory entry should be skipped (hardcoded dirs + gitignore). */
566+
/* Snapshot of the cache directory for the current walk.
567+
*
568+
* cbm_resolve_cache_dir() returns a pointer to a static thread-local buffer, so
569+
* calling it once per directory — as an earlier version of this prune did —
570+
* rewrites a buffer other code may still be holding. Resolve once at the entry
571+
* point and compare against the copy. */
572+
static _Thread_local char g_walk_cache_dir[CBM_SZ_4K];
573+
574+
static void walk_cache_dir_snapshot(void) {
575+
const char *cache = cbm_workspace_cache_dir();
576+
snprintf(g_walk_cache_dir, sizeof(g_walk_cache_dir), "%s", cache ? cache : "");
577+
}
578+
579+
/* The cache directory holds every indexed project's graph database. When a custom
580+
* CBM_CACHE_DIR sits inside a repository — which happens in tests and is legal in
581+
* production — walking into it would pull other projects' databases into this
582+
* project's file list. Prune it by absolute path.
583+
*
584+
* This is the narrow remedy for a concern that was briefly implemented as
585+
* refusing any root containing the cache: refusing a whole root was too blunt,
586+
* and not walking the cache is what the concern actually asks for. */
587+
static bool dir_is_cache_tree(const char *abs_path) {
588+
const char *cache = g_walk_cache_dir;
589+
if (!cache[0] || !abs_path || !abs_path[0]) {
590+
return false;
591+
}
592+
size_t n = strlen(cache);
593+
while (n > 1 && (cache[n - 1] == '/' || cache[n - 1] == '\\')) {
594+
n--;
595+
}
596+
if (strncmp(abs_path, cache, n) != 0) {
597+
return false;
598+
}
599+
/* Boundary-aware so "<cache>x" is not treated as living under "<cache>". */
600+
return abs_path[n] == '\0' || abs_path[n] == '/' || abs_path[n] == '\\';
601+
}
602+
565603
static bool should_skip_directory(const char *entry_name, const char *rel_path,
566604
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
567605
const cbm_gitignore_t *global_gi,
@@ -853,7 +891,8 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
853891
}
854892

855893
if (S_ISDIR(st.st_mode)) {
856-
if (!should_skip_directory(entry->name, rel_path, opts, gitignore, global_gi, cbmignore,
894+
if (!dir_is_cache_tree(abs_path) &&
895+
!should_skip_directory(entry->name, rel_path, opts, gitignore, global_gi, cbmignore,
857896
frame->local_gi, frame->local_gi_prefix)) {
858897
walk_push_subdir(ws, abs_path, rel_path, frame, out);
859898
} else {
@@ -1164,6 +1203,7 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
11641203
.collect_excluded = !count_only && excluded_out != NULL,
11651204
.collect_ignored = !count_only && ignored_out != NULL,
11661205
};
1206+
walk_cache_dir_snapshot();
11671207
walk_dir(repo_path, "", opts, gitignore, global_gi, cbmignore, &fl);
11681208

11691209
/* Cleanup */

‎src/foundation/workspace.c‎

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "foundation/compat.h"
88
#include "foundation/compat_fs.h"
99
#include "foundation/platform.h"
10+
#include "foundation/sha256.h"
1011

1112
#include <stdlib.h>
1213

@@ -511,6 +512,18 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir
511512
* then applies to paths that ARE inside the declared root but are still too
512513
* broad to index as one unit. */
513514
bool boundary_declared = grants > 0 || configured;
515+
/* No manifest consultation here, deliberately.
516+
*
517+
* A project's manifest authorizes outside roots FOR THAT PROJECT, so the
518+
* question it answers is "may project P pull in tree T", not "may T be indexed
519+
* standalone". This function only ever sees one path, so it has no project
520+
* context to ask that question with — an earlier draft passed the candidate as
521+
* its own project root, which read a manifest that by definition was not the
522+
* one that requested it and so never authorized anything.
523+
*
524+
* The consuming half belongs where the project context exists: discovery
525+
* walking a project's approved extra roots. cbm_workspace_manifest_allows is
526+
* the query that half will use. */
514527
if (boundary_declared && !match.contained && !configured_contains) {
515528
if (err) {
516529
/* Keep the "outside the allowed root" wording: changing it broke an
@@ -564,3 +577,214 @@ const char *cbm_workspace_home_dir(void) {
564577
const char *cbm_workspace_cache_dir(void) {
565578
return cbm_resolve_cache_dir();
566579
}
580+
581+
/* ── Per-project request manifest ─────────────────────────────────────────── */
582+
583+
bool cbm_workspace_manifest_read(const char *project_root, cbm_ws_manifest_t *out) {
584+
if (!out) {
585+
return false;
586+
}
587+
memset(out, 0, sizeof(*out));
588+
if (!project_root || !project_root[0]) {
589+
return true; /* nothing to read is not a malformed manifest */
590+
}
591+
592+
char path[WS_LINE_MAX];
593+
int n = snprintf(path, sizeof(path), "%s/%s", project_root, CBM_WS_MANIFEST_NAME);
594+
if (n <= 0 || (size_t)n >= sizeof(path)) {
595+
return true;
596+
}
597+
FILE *f = cbm_fopen(path, "rb");
598+
if (!f) {
599+
return true;
600+
}
601+
602+
/* Hash the raw bytes, not the parsed entries: a comment or ordering change is
603+
* still a change the approver has not seen, and should lapse approval. */
604+
char *raw = NULL;
605+
size_t raw_len = 0;
606+
char chunk[WS_LINE_MAX];
607+
size_t got = 0;
608+
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0) {
609+
char *grown = realloc(raw, raw_len + got);
610+
if (!grown) {
611+
free(raw);
612+
(void)fclose(f);
613+
return false;
614+
}
615+
raw = grown;
616+
memcpy(raw + raw_len, chunk, got);
617+
raw_len += got;
618+
}
619+
(void)fclose(f);
620+
out->present = true;
621+
cbm_sha256_hex(raw ? raw : "", raw_len, out->digest);
622+
623+
/* Parse entries out of the same bytes. */
624+
bool ok = true;
625+
size_t line_start = 0;
626+
for (size_t i = 0; i <= raw_len && ok; i++) {
627+
bool eol = (i == raw_len) || raw[i] == '\n';
628+
if (!eol) {
629+
continue;
630+
}
631+
size_t len = i - line_start;
632+
while (len > 0 && (raw[line_start + len - 1] == '\r' || raw[line_start + len - 1] == ' ')) {
633+
len--;
634+
}
635+
if (len > 0 && raw[line_start] != '#') {
636+
if (len >= sizeof(out->entries[0]) || out->count >= CBM_WS_MANIFEST_MAX_ENTRIES) {
637+
ok = false;
638+
break;
639+
}
640+
/* A control character in a path is never legitimate and is exactly how
641+
* a crafted entry would try to smuggle a second value past a reader. */
642+
for (size_t k = 0; k < len; k++) {
643+
unsigned char c = (unsigned char)raw[line_start + k];
644+
if (c < 0x20) {
645+
ok = false;
646+
break;
647+
}
648+
}
649+
if (!ok) {
650+
break;
651+
}
652+
memcpy(out->entries[out->count], raw + line_start, len);
653+
out->entries[out->count][len] = '\0';
654+
out->count++;
655+
}
656+
line_start = i + 1;
657+
}
658+
free(raw);
659+
if (!ok) {
660+
memset(out, 0, sizeof(*out));
661+
}
662+
return ok;
663+
}
664+
665+
static bool ws_approval_path(const char *cache_dir, char *out, size_t out_sz) {
666+
if (!cache_dir || !cache_dir[0]) {
667+
return false;
668+
}
669+
int n = snprintf(out, out_sz, "%s/approved_manifests", cache_dir);
670+
return n > 0 && (size_t)n < out_sz;
671+
}
672+
673+
bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *project_root,
674+
const cbm_ws_manifest_t *manifest) {
675+
if (!manifest || !manifest->present || !manifest->digest[0] || !project_root) {
676+
return false;
677+
}
678+
char store[WS_LINE_MAX];
679+
if (!ws_approval_path(cache_dir, store, sizeof(store))) {
680+
return false;
681+
}
682+
FILE *f = cbm_fopen(store, "r");
683+
if (!f) {
684+
return false;
685+
}
686+
char line[WS_LINE_MAX];
687+
bool found = false;
688+
while (!found && fgets(line, (int)sizeof(line), f)) {
689+
size_t len = strlen(line);
690+
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
691+
line[--len] = '\0';
692+
}
693+
char *sep = strchr(line, ' ');
694+
if (!sep) {
695+
continue;
696+
}
697+
*sep = '\0';
698+
/* Both the digest and the project must match: an approval is for this
699+
* content in this project, not for the content anywhere. */
700+
found = strcmp(line, manifest->digest) == 0 && ws_paths_equal(sep + 1, project_root);
701+
}
702+
(void)fclose(f);
703+
return found;
704+
}
705+
706+
bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir,
707+
const char *project_root, char *err, size_t err_sz) {
708+
if (err && err_sz) {
709+
err[0] = '\0';
710+
}
711+
cbm_ws_manifest_t m;
712+
if (!cbm_workspace_manifest_read(project_root, &m)) {
713+
if (err) {
714+
snprintf(err, err_sz,
715+
"%s is malformed (control characters, an over-long entry, or "
716+
"more than %d entries)",
717+
CBM_WS_MANIFEST_NAME, CBM_WS_MANIFEST_MAX_ENTRIES);
718+
}
719+
return false;
720+
}
721+
if (!m.present) {
722+
if (err) {
723+
snprintf(err, err_sz, "no %s in %s", CBM_WS_MANIFEST_NAME,
724+
project_root ? project_root : "(none)");
725+
}
726+
return false;
727+
}
728+
729+
/* Approving a manifest must not become a way around the breadth policy: every
730+
* requested entry has to stand on its own as an indexing root. */
731+
for (int i = 0; i < m.count; i++) {
732+
cbm_ws_verdict_t v = cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir);
733+
if (v != CBM_WS_ALLOW) {
734+
if (err) {
735+
snprintf(err, err_sz, "requested path %s: %s", m.entries[i],
736+
cbm_workspace_verdict_reason(v));
737+
}
738+
return false;
739+
}
740+
}
741+
742+
char store[WS_LINE_MAX];
743+
if (!ws_approval_path(cache_dir, store, sizeof(store))) {
744+
if (err) {
745+
snprintf(err, err_sz, "cache path too long");
746+
}
747+
return false;
748+
}
749+
if (cbm_workspace_manifest_is_approved(cache_dir, project_root, &m)) {
750+
return true;
751+
}
752+
FILE *f = cbm_fopen(store, "a");
753+
if (!f) {
754+
if (err) {
755+
snprintf(err, err_sz, "cannot write %s", store);
756+
}
757+
return false;
758+
}
759+
(void)fprintf(f, "%s %s\n", m.digest, project_root);
760+
bool ok = fclose(f) == 0;
761+
if (!ok && err) {
762+
snprintf(err, err_sz, "cannot write %s", store);
763+
}
764+
return ok;
765+
}
766+
767+
bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir,
768+
const char *project_root, const char *candidate) {
769+
if (!candidate || !candidate[0]) {
770+
return false;
771+
}
772+
cbm_ws_manifest_t m;
773+
if (!cbm_workspace_manifest_read(project_root, &m) || !m.present) {
774+
return false;
775+
}
776+
if (!cbm_workspace_manifest_is_approved(cache_dir, project_root, &m)) {
777+
return false;
778+
}
779+
for (int i = 0; i < m.count; i++) {
780+
/* Re-classify at use time as well as at approval time: the credential list
781+
* may have grown since, and a stored approval must not outrank it. */
782+
if (cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir) != CBM_WS_ALLOW) {
783+
continue;
784+
}
785+
if (cbm_path_within_root(m.entries[i], candidate)) {
786+
return true;
787+
}
788+
}
789+
return false;
790+
}

0 commit comments

Comments
 (0)