|
7 | 7 | #include "foundation/compat.h" |
8 | 8 | #include "foundation/compat_fs.h" |
9 | 9 | #include "foundation/platform.h" |
| 10 | +#include "foundation/sha256.h" |
10 | 11 |
|
11 | 12 | #include <stdlib.h> |
12 | 13 |
|
@@ -511,6 +512,18 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir |
511 | 512 | * then applies to paths that ARE inside the declared root but are still too |
512 | 513 | * broad to index as one unit. */ |
513 | 514 | 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. */ |
514 | 527 | if (boundary_declared && !match.contained && !configured_contains) { |
515 | 528 | if (err) { |
516 | 529 | /* Keep the "outside the allowed root" wording: changing it broke an |
@@ -564,3 +577,214 @@ const char *cbm_workspace_home_dir(void) { |
564 | 577 | const char *cbm_workspace_cache_dir(void) { |
565 | 578 | return cbm_resolve_cache_dir(); |
566 | 579 | } |
| 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