From c442028010bee9b4ae4e24905fad7fdcd8966b34 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 21:36:08 +0200 Subject: [PATCH] fix(workspace): classify manifest entries in their resolved form A project's .cbmpathwhitelist lists extra roots it would like indexed. When such a manifest was approved, and again each time it was consulted, every entry was classified exactly as written in the file, while the directory that would actually be indexed is the one the entry resolves to. An entry that reached the home directory, a credential directory or the volume root through a link or through `..` segments was judged by its spelling and passed as an ordinary path; only the containment check resolved it. An entry naming a directory that did not exist was approved as well. Both cbm_workspace_manifest_approve and cbm_workspace_manifest_allows now resolve each entry with cbm_canonical_path, the resolver every other caller of the policy applies, and classify that form. Approval refuses an entry that is relative, does not exist or is not a directory, with a message that names the entry and, when the resolved directory differs from what was written, shows both. Approval stays bound to the manifest text; where an entry leads is read again when it is used, so a link that is retargeted after approval is judged by its new target. At use time an entry that no longer resolves matches nothing, and the other entries of the manifest are consulted as before. An ordinary, existing, absolute entry keeps its result. A manifest that names a directory which does not exist yet is now refused at approval and has to be approved once the directory is there. Signed-off-by: Martin Vogel --- src/foundation/workspace.c | 69 ++++++- src/foundation/workspace.h | 15 +- tests/test_workspace.c | 384 +++++++++++++++++++++++++++++++++++-- 3 files changed, 441 insertions(+), 27 deletions(-) diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index 40e009a03..eab52d48f 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -776,6 +776,43 @@ bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *proje return found; } +/* Resolve a manifest entry to the directory it names — the form the policy is + * defined over. The file holds the entry as a person wrote it, and a spelling + * can reach a directory through a link or through `..` segments; the directory + * that would be indexed is the resolved one, so that is the one classified, at + * approval and again at use. Only an absolute entry is accepted: the manifest + * is read by a long-lived process whose working directory is unrelated to the + * project, so a relative spelling has no base it could honestly be resolved + * against. Returns NULL on success, otherwise the reason the entry is unusable. + * out must hold WS_LINE_MAX bytes. */ +static const char *ws_manifest_entry_resolve(const char *entry, char *out, size_t out_sz) { + if (ws_volume_prefix_len(entry) == 0) { + return "must be an absolute path"; + } + if (!cbm_canonical_path(entry, out, out_sz)) { + return "must name an existing directory"; + } + if (!cbm_is_dir(out)) { + return "is not a directory"; + } + return NULL; +} + +/* "requested path : ", with the resolved form shown when it + * differs from what was written: the person typed one and the policy judged + * the other. */ +static void ws_manifest_entry_refusal(char *err, size_t err_sz, const char *entry, + const char *resolved, const char *reason) { + if (!err || err_sz == 0) { + return; + } + if (resolved && strcmp(resolved, entry) != 0) { + snprintf(err, err_sz, "requested path %s (resolves to %s): %s", entry, resolved, reason); + } else { + snprintf(err, err_sz, "requested path %s: %s", entry, reason); + } +} + bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, const char *project_root, char *err, size_t err_sz) { if (err && err_sz) { @@ -800,14 +837,19 @@ bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, } /* Approving a manifest must not become a way around the breadth policy: every - * requested entry has to stand on its own as an indexing root. */ + * requested entry has to stand on its own as an indexing root, judged by the + * directory it resolves to. */ for (int i = 0; i < m.count; i++) { - cbm_ws_verdict_t v = cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir); + char resolved[WS_LINE_MAX]; + const char *unusable = ws_manifest_entry_resolve(m.entries[i], resolved, sizeof(resolved)); + if (unusable) { + ws_manifest_entry_refusal(err, err_sz, m.entries[i], NULL, unusable); + return false; + } + cbm_ws_verdict_t v = cbm_workspace_classify_root(resolved, home_dir, cache_dir); if (v != CBM_WS_ALLOW) { - if (err) { - snprintf(err, err_sz, "requested path %s: %s", m.entries[i], - cbm_workspace_verdict_reason(v)); - } + ws_manifest_entry_refusal(err, err_sz, m.entries[i], resolved, + cbm_workspace_verdict_reason(v)); return false; } } @@ -850,12 +892,19 @@ bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir, return false; } for (int i = 0; i < m.count; i++) { - /* Re-classify at use time as well as at approval time: the credential list - * may have grown since, and a stored approval must not outrank it. */ - if (cbm_workspace_classify_root(m.entries[i], home_dir, cache_dir) != CBM_WS_ALLOW) { + /* Resolve and re-classify at use time as well as at approval time: the + * credential list may have grown since, and a link may lead somewhere + * else than it did when the person approved it. A stored approval must + * not outrank either. An entry that no longer resolves matches nothing, + * and the other entries of the manifest are consulted as before. */ + char resolved[WS_LINE_MAX]; + if (ws_manifest_entry_resolve(m.entries[i], resolved, sizeof(resolved)) != NULL) { continue; } - if (cbm_path_within_root(m.entries[i], candidate)) { + if (cbm_workspace_classify_root(resolved, home_dir, cache_dir) != CBM_WS_ALLOW) { + continue; + } + if (cbm_path_within_root(resolved, candidate)) { return true; } } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index 276079155..c4674696d 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -145,12 +145,19 @@ bool cbm_workspace_manifest_read(const char *project_root, cbm_ws_manifest_t *ou bool cbm_workspace_manifest_is_approved(const char *cache_dir, const char *project_root, const cbm_ws_manifest_t *manifest); -/* Record approval for the manifest currently on disk. Refuses when a requested - * entry would not be allowable as a root on its own — approving a manifest must - * not become a way around the breadth policy. */ +/* Record approval for the manifest currently on disk. Each entry is judged in + * its resolved form (links followed, `..` collapsed), since that is the + * directory that would be indexed; an entry that is relative, does not exist + * or is not a directory is refused, as is one that would not be allowable as a + * root on its own — approving a manifest must not become a way around the + * breadth policy. err names the entry as written and, when it differs, the + * directory it resolves to. */ bool cbm_workspace_manifest_approve(const char *cache_dir, const char *home_dir, const char *project_root, char *err, size_t err_sz); -/* True when candidate is at or below an APPROVED manifest entry of project_root. */ +/* True when candidate is at or below an APPROVED manifest entry of project_root. + * Entries are resolved and classified again here: approval binds to the + * manifest text, and where an entry leads is read at the time it is used. An + * entry that no longer resolves matches nothing. */ bool cbm_workspace_manifest_allows(const char *cache_dir, const char *home_dir, const char *project_root, const char *candidate); diff --git a/tests/test_workspace.c b/tests/test_workspace.c index afe6726e2..7f163db29 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -334,13 +334,29 @@ TEST(ws_manifest_rejects_control_characters) { /* THE property the design rests on: a manifest grants nothing until a person * approves it, and editing it lapses that approval rather than inheriting it. */ TEST(ws_manifest_approval_is_keyed_to_content) { - char *base = th_mktempdir("cbm_ws_m3"); - char *cache = th_mktempdir("cbm_ws_m3c"); - ASSERT(base != NULL); - ASSERT(cache != NULL); + /* th_mktempdir returns one static buffer: copy each result at once. */ + char base[256]; + char cache[256]; + char *created = th_mktempdir("cbm_ws_m3"); + ASSERT(created != NULL); + snprintf(base, sizeof(base), "%s", created); + created = th_mktempdir("cbm_ws_m3c"); + ASSERT(created != NULL); + snprintf(cache, sizeof(cache), "%s", created); + + /* Entries must name existing directories. */ + char sdk[1024]; + char protos[1024]; + snprintf(sdk, sizeof(sdk), "%s/sdk", base); + snprintf(protos, sizeof(protos), "%s/protos", base); + ASSERT_EQ(cbm_mkdir(sdk), 0); + ASSERT_EQ(cbm_mkdir(protos), 0); + char path[1024]; + char body[4096]; snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME); - ws_write(path, "/opt/sdk\n"); + snprintf(body, sizeof(body), "%s\n", sdk); + ws_write(path, body); cbm_ws_manifest_t before; ASSERT_TRUE(cbm_workspace_manifest_read(base, &before)); @@ -352,7 +368,8 @@ TEST(ws_manifest_approval_is_keyed_to_content) { ASSERT_TRUE(cbm_workspace_manifest_is_approved(cache, base, &before)); /* Widen the requests, as a `git pull` would. Approval must lapse. */ - ws_write(path, "/opt/sdk\n/srv/protos\n"); + snprintf(body, sizeof(body), "%s\n%s\n", sdk, protos); + ws_write(path, body); cbm_ws_manifest_t after; ASSERT_TRUE(cbm_workspace_manifest_read(base, &after)); ASSERT_TRUE(strcmp(before.digest, after.digest) != 0); @@ -363,18 +380,35 @@ TEST(ws_manifest_approval_is_keyed_to_content) { PASS(); } -/* Approving a manifest must not become a route around the breadth policy. */ +/* Approving a manifest must not become a route around the breadth policy. The + * entry has to exist, so the overbroad directory is "/etc" on POSIX and, on + * Windows, where one component below the drive is already an ordinary + * workspace, the drive root of the temporary directory. */ TEST(ws_manifest_approval_refuses_overbroad_requests) { - char *base = th_mktempdir("cbm_ws_m4"); - char *cache = th_mktempdir("cbm_ws_m4c"); - ASSERT(base != NULL); - ASSERT(cache != NULL); + char base[256]; + char cache[256]; + char *created = th_mktempdir("cbm_ws_m4"); + ASSERT(created != NULL); + snprintf(base, sizeof(base), "%s", created); + created = th_mktempdir("cbm_ws_m4c"); + ASSERT(created != NULL); + snprintf(cache, sizeof(cache), "%s", created); + char path[1024]; + char body[1024]; + const char *refusal = NULL; snprintf(path, sizeof(path), "%s/%s", base, CBM_WS_MANIFEST_NAME); - ws_write(path, "/etc\n"); +#ifdef _WIN32 + snprintf(body, sizeof(body), "%.3s\n", base); + refusal = cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE); +#else + snprintf(body, sizeof(body), "/etc\n"); + refusal = cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW); +#endif + ws_write(path, body); char err[1024]; ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); - ASSERT_TRUE(strstr(err, "too broad") != NULL); + ASSERT_TRUE(strstr(err, refusal) != NULL); th_cleanup(base); th_cleanup(cache); PASS(); @@ -463,6 +497,323 @@ TEST(ws_linked_home_classified_as_home) { #endif } +/* ── Manifest entries are judged in their resolved form ──────────────────── */ + +enum { WS_T_PATH = 4096 }; + +/* th_mktempdir hands back one static buffer, so copy at once — and in canonical + * form, because the policy compares resolved spellings and /tmp is a link on + * macOS. */ +static bool ws_tempdir_canonical(const char *prefix, char *out, size_t out_sz) { + char *created = th_mktempdir(prefix); + return created && cbm_canonical_path(created, out, out_sz) == 1; +} + +static bool ws_join(char *out, size_t out_sz, const char *a, const char *b) { + int n = snprintf(out, out_sz, "%s/%s", a, b); + return n > 0 && (size_t)n < out_sz; +} + +/* Create / and return it in canonical form. */ +static bool ws_mkdir_canonical(const char *parent, const char *name, char *out, size_t out_sz) { + char made[WS_T_PATH]; + if (!ws_join(made, sizeof(made), parent, name) || cbm_mkdir(made) != 0) { + return false; + } + return cbm_canonical_path(made, out, out_sz) == 1; +} + +static void ws_manifest_write(const char *base, const char *body) { + char path[WS_T_PATH]; + if (ws_join(path, sizeof(path), base, CBM_WS_MANIFEST_NAME)) { + ws_write(path, body); + } +} + +static void ws_manifest_write_line(const char *base, const char *entry) { + char body[WS_T_PATH]; + int n = snprintf(body, sizeof(body), "%s\n", entry); + if (n > 0 && (size_t)n < sizeof(body)) { + ws_manifest_write(base, body); + } +} + +/* Put the manifest on record as approved by writing the store line directly, + * in the form cbm_workspace_manifest_approve writes (" "). + * This is the record an approval leaves behind when the entry was judged by + * its spelling, and the use-time check must stand on its own against it. */ +static bool ws_manifest_record_approval(const char *cache, const char *base) { + cbm_ws_manifest_t m; + if (!cbm_workspace_manifest_read(base, &m) || !m.present) { + return false; + } + char store[WS_T_PATH]; + char line[WS_T_PATH]; + if (!ws_join(store, sizeof(store), cache, "approved_manifests")) { + return false; + } + int n = snprintf(line, sizeof(line), "%s %s\n", m.digest, base); + if (n <= 0 || (size_t)n >= sizeof(line)) { + return false; + } + ws_write(store, line); + return cbm_workspace_manifest_is_approved(cache, base, &m); +} + +/* The directory a manifest entry names is the one that would be indexed, so + * that is the form the policy judges — at approval and again at use. Spelled + * through `..`, an entry reads as an ordinary path several components deep + * while naming the home directory or the volume root. */ +TEST(ws_manifest_entry_classified_in_resolved_form) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m5", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m5c", cache, sizeof(cache))); + + char home[WS_T_PATH]; + char src[WS_T_PATH]; + char home_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(base, "src", src, sizeof(src))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + + char entry[WS_T_PATH]; + char expected[WS_T_PATH]; + char err[1024]; + + /* "/src/../home" is the home directory. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), src, "../home")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", home) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + /* An approval already on record for this spelling does not outrank the + * use-time check. */ + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + + /* Enough `..` segments reach the volume root from anywhere. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "../../../../../../../../../../../..")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE))); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* An entry that does not resolve to a directory is refused at approval, naming + * the entry; at use it simply matches nothing, and the other entries of the + * same manifest keep working. */ +TEST(ws_manifest_unresolvable_entry_refused) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m6", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m6c", cache, sizeof(cache))); + + char entry[WS_T_PATH]; + char err[1024]; + + /* Missing. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "missing")); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, "must name an existing directory")); + + /* Present, but a file. */ + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "notes.txt")); + ws_write(entry, "not a directory\n"); + ws_manifest_write_line(base, entry); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, entry)); + ASSERT_NOT_NULL(strstr(err, "is not a directory")); + + /* Relative: the file is read by a long-lived process whose working + * directory is unrelated to the project, so there is no base to resolve + * it against. */ + ws_manifest_write_line(base, "src"); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "requested path src:")); + ASSERT_NOT_NULL(strstr(err, "must be an absolute path")); + + /* At use: a directory that disappeared after approval matches nothing and + * does not take the rest of the manifest with it. */ + char present[WS_T_PATH]; + char present_sub[WS_T_PATH]; + char body[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "present", present, sizeof(present))); + ASSERT_TRUE(ws_mkdir_canonical(present, "inc", present_sub, sizeof(present_sub))); + ASSERT_TRUE(ws_join(entry, sizeof(entry), base, "gone")); + ASSERT(snprintf(body, sizeof(body), "%s\n%s\n", entry, present) > 0); + ws_manifest_write(base, body); + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, present_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, HOME, base, base)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* The ordinary shapes keep their result: an absolute existing directory, an + * entry equal to the project root, an entry outside every root in the grant + * store (the manifest is consulted on its own), and on POSIX a link to an + * ordinary directory. */ +TEST(ws_manifest_ordinary_entries_unchanged) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m7", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m7c", cache, sizeof(cache))); + + char sdk[WS_T_PATH]; + char sdk_inc[WS_T_PATH]; + char granted[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "sdk", sdk, sizeof(sdk))); + ASSERT_TRUE(ws_mkdir_canonical(sdk, "inc", sdk_inc, sizeof(sdk_inc))); + ASSERT_TRUE(ws_mkdir_canonical(base, "granted", granted, sizeof(granted))); + + char err[1024]; + /* The grant store names a root the sdk is not under. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, err, sizeof(err))); + + ws_manifest_write_line(base, sdk); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_STR_EQ(err, ""); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, HOME, base, granted)); + + /* Equal to the project root. */ + ws_manifest_write_line(base, base); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, base)); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); + +#ifndef _WIN32 + /* A link to an ordinary directory is that directory. Windows is not + * covered here: symlink(2) has no counterpart in the test helpers, and + * CreateSymbolicLinkW needs a privilege the CI runner does not hold; the + * resolved-form rules themselves are exercised on every platform by the + * `..` cases above. */ + char alias[WS_T_PATH]; + ASSERT_TRUE(ws_join(alias, sizeof(alias), base, "alias")); + ASSERT_EQ(symlink(sdk, alias), 0); + ws_manifest_write_line(base, alias); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, HOME, base, sdk_inc)); +#endif + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +#ifndef _WIN32 +/* A link is judged by where it leads. Windows is not covered: symlink(2) has + * no counterpart in the test helpers and CreateSymbolicLinkW needs a privilege + * the CI runner does not hold; the resolved-form rules are exercised on every + * platform by ws_manifest_entry_classified_in_resolved_form. */ +TEST(ws_manifest_link_to_sensitive_directory_refused) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m8", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m8c", cache, sizeof(cache))); + + char home[WS_T_PATH]; + char home_sub[WS_T_PATH]; + char keys[WS_T_PATH]; + char keys_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + ASSERT_TRUE(ws_mkdir_canonical(base, ".ssh", keys, sizeof(keys))); + ASSERT_TRUE(ws_mkdir_canonical(keys, "hosts", keys_sub, sizeof(keys_sub))); + + char to_home[WS_T_PATH]; + char to_keys[WS_T_PATH]; + ASSERT_TRUE(ws_join(to_home, sizeof(to_home), base, "to-home")); + ASSERT_TRUE(ws_join(to_keys, sizeof(to_keys), base, "to-keys")); + ASSERT_EQ(symlink(home, to_home), 0); + ASSERT_EQ(symlink(keys, to_keys), 0); + + char expected[WS_T_PATH]; + char err[1024]; + + ws_manifest_write_line(base, to_home); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, to_home)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", home) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + ws_manifest_write_line(base, to_keys); + ASSERT_FALSE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, to_keys)); + ASSERT(snprintf(expected, sizeof(expected), "(resolves to %s)", keys) > 0); + ASSERT_NOT_NULL(strstr(err, expected)); + ASSERT_NOT_NULL(strstr(err, cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE))); + + /* On record as approved: the use-time check still refuses both. */ + char body[WS_T_PATH]; + ASSERT(snprintf(body, sizeof(body), "%s\n%s\n", to_home, to_keys) > 0); + ws_manifest_write(base, body); + ASSERT_TRUE(ws_manifest_record_approval(cache, base)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, keys_sub)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} + +/* Approval binds to the manifest text; where a link leads is read again each + * time the manifest is used. A link approved while it led to an ordinary + * directory and later pointed at the home directory grants nothing there. + * POSIX only, for the reason given at the test above. */ +TEST(ws_manifest_link_retarget_is_seen_at_use) { + char base[WS_T_PATH]; + char cache[WS_T_PATH]; + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m9", base, sizeof(base))); + ASSERT_TRUE(ws_tempdir_canonical("cbm_ws_m9c", cache, sizeof(cache))); + + char shared[WS_T_PATH]; + char shared_sub[WS_T_PATH]; + char home[WS_T_PATH]; + char home_sub[WS_T_PATH]; + ASSERT_TRUE(ws_mkdir_canonical(base, "shared", shared, sizeof(shared))); + ASSERT_TRUE(ws_mkdir_canonical(shared, "proto", shared_sub, sizeof(shared_sub))); + ASSERT_TRUE(ws_mkdir_canonical(base, "home", home, sizeof(home))); + ASSERT_TRUE(ws_mkdir_canonical(home, "notes", home_sub, sizeof(home_sub))); + + char extra[WS_T_PATH]; + ASSERT_TRUE(ws_join(extra, sizeof(extra), base, "extra")); + ASSERT_EQ(symlink(shared, extra), 0); + + char err[1024]; + ws_manifest_write_line(base, extra); + ASSERT_TRUE(cbm_workspace_manifest_approve(cache, home, base, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_manifest_allows(cache, home, base, shared_sub)); + + /* Same text, new target. */ + ASSERT_EQ(unlink(extra), 0); + ASSERT_EQ(symlink(home, extra), 0); + cbm_ws_manifest_t m; + ASSERT_TRUE(cbm_workspace_manifest_read(base, &m)); + ASSERT_TRUE(cbm_workspace_manifest_is_approved(cache, base, &m)); + + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, home_sub)); + ASSERT_FALSE(cbm_workspace_manifest_allows(cache, home, base, shared_sub)); + + th_cleanup(base); + th_cleanup(cache); + PASS(); +} +#endif /* _WIN32 */ + SUITE(workspace) { RUN_TEST(ws_manifest_absent_is_not_an_error); RUN_TEST(ws_manifest_parses_entries_and_skips_comments); @@ -486,4 +837,11 @@ SUITE(workspace) { RUN_TEST(ws_every_verdict_has_a_reason); RUN_TEST(ws_home_dir_is_resolved); RUN_TEST(ws_linked_home_classified_as_home); + RUN_TEST(ws_manifest_entry_classified_in_resolved_form); + RUN_TEST(ws_manifest_unresolvable_entry_refused); + RUN_TEST(ws_manifest_ordinary_entries_unchanged); +#ifndef _WIN32 + RUN_TEST(ws_manifest_link_to_sensitive_directory_refused); + RUN_TEST(ws_manifest_link_retarget_is_seen_at_use); +#endif }