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 }