#define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include "store.h" /** * TERMINOLOGY * * - Clip store: A pairing of a file containing snips and a content directory * - Clip store entry: A single pair of snip and content entry * - Snip file: A file which maps lines to content entries using hashes * - Snip: A (hash, line) pair in the clip store, representing a content entry * - Content entry: The full data stored from the clipboard * - Content directory: The directory in which the content entries are stored * * KEY FUNCTIONS: * * - cs_add - add a clip store entry * - cs_remove - remove a clip store entry by callback * - cs_trim - trim to the newest/oldest N entries * - cs_snip_iter - iterate over snip hashes and lines * - cs_content_get - get the content for a snip hash * * CLIP STORE DESIGN * * Our primary focus is on achieving high efficiency in appending new snips, * iterating over the entire list of snips, and replacing the final snip in the * snip file. For this reason complexity is avoided in processing deletions, * since deletions are usually rare. This avoids us having to implement (for * example) tombstones, which would slow things down and complicate iteration. * * CONTENT DIRECTORY DESIGN * * The content directory is extremely simple: it contains files with the same * name as the snip hash. This allows quickly going from the one line summary * in the snip to the full contents. * * SYNCHRONISATION * * The clip store's size may be increased or decreased by another program using * the library, so before each operation we must take a lock and check if the * header was updated. If it was, we update the size of the mmapped area to * suit. The lock is implemented using flock() on cs->snip_fd, see cs_ref(), * cs_ref_no_update(), and cs_unref(). */ /** * Calculate the needed file size in bytes for @nr_snips snips, adding the * header. * * @nr_snips: The number of snips to calculate for */ static size_t _must_use_ cs_file_size(size_t nr_snips) { return (nr_snips + 1) * CS_SNIP_SIZE; } /** * Validate the consistency of the clip store's header information. * * @cs: The clip store to operate on * @file_size: The current size of the file */ static int _must_use_ _nonnull_ cs_header_validate(const struct clip_store *cs, size_t file_size) { if (cs->header->nr_snips > cs->header->nr_snips_alloc || (cs->header->nr_snips_alloc + 1) * CS_SNIP_SIZE != file_size) { return -EINVAL; } return 0; } static int _must_use_ _nonnull_ cs_remap(struct clip_store *cs, size_t old_size, size_t new_size) { struct cs_header *new_header = mmap(NULL, new_size, PROT_READ | PROT_WRITE, MAP_SHARED, cs->snip_fd, 0); if (new_header == MAP_FAILED) { return negative_errno(); } if (munmap(cs->header, old_size) < 0) { int ret = negative_errno(); munmap(new_header, new_size); return ret; } cs->header = new_header; cs->snips = (struct cs_snip *)(cs->header + 1); cs->mapped_file_size = new_size; return 0; } /** * Decrease the reference count for the clip store lock, unrefing it if * the refcount reaches zero. * * @cs: The clip store to operate on */ void cs_unref(struct clip_store *cs) { expect(cs->refcount > 0); cs->refcount--; if (cs->refcount == 0) { expect(flock(cs->snip_fd, LOCK_UN) == 0); } } /** * Increase the reference count for the clip store lock. * * @cs: The clip store to operate on */ static struct ref_guard _must_use_ _nonnull_ cs_ref_no_update(struct clip_store *cs) { struct ref_guard guard = {.status = 0, .unref = cs_unref, .cs = cs}; if (cs->refcount == 0) { expect(flock(cs->snip_fd, LOCK_EX) == 0); } static_assert(sizeof(cs->refcount) == sizeof(size_t), "refcount type wrong"); expect(cs->refcount < SIZE_MAX); cs->refcount++; return guard; } /** * Increase the reference count for the clip store lock, and remap as needed if * the header values have changed. * * Even if the guard status indicates an error, you must still call cs_unref(). * * @cs: The clip store to operate on */ struct ref_guard cs_ref(struct clip_store *cs) { struct ref_guard guard = cs_ref_no_update(cs); if (cs->refcount > 1) { // We're an inner reference, so any necessary remapping has already // been performed. return guard; } if (cs->local_nr_snips != cs->header->nr_snips || cs->local_nr_snips_alloc != cs->header->nr_snips_alloc) { struct stat st; if (fstat(cs->snip_fd, &st) < 0) { guard.status = negative_errno(); return guard; } int ret = cs_header_validate(cs, (size_t)st.st_size); if (ret < 0) { guard.status = ret; return guard; } // If we shrank, no need to remap, since we'll just use the new bounds. if (cs->local_nr_snips_alloc < cs->header->nr_snips_alloc) { int remap_ret = cs_remap(cs, cs->mapped_file_size, cs_file_size(cs->header->nr_snips_alloc)); if (remap_ret < 0) { guard.status = remap_ret; return guard; } } cs->local_nr_snips = cs->header->nr_snips; cs->local_nr_snips_alloc = cs->header->nr_snips_alloc; } return guard; } /** * _drop_() function for when a `ref_guard` structure goes out of scope. * * @guard: The guard lock */ void drop_cs_unref(struct ref_guard *guard) { guard->status = -EBADF; guard->unref(guard->cs); } /** * Destroy the clip store, releasing all of its resources. * * @cs: The clip store to operate on */ int cs_destroy(struct clip_store *cs) { cs->ready = false; if (munmap(cs->header, cs->mapped_file_size)) { return negative_errno(); } return 0; } /** * _drop_() function for when a `clip_store` goes out of scope. * * @guard: The guard lock */ void drop_cs_destroy(struct clip_store *cs) { expect(cs_destroy(cs) == 0); } /** * Initialise a `struct clip_store` with snip_fd open to a file for snip * storage and content_fd open to a directory for content entry storage. * * The snip file is extended and the header snip is written if the file size is * zero. The file is mapped into memory until cs_destroy() is called. * * @cs: The clip store to initialise * @snip_fd: Open file descriptor for the snip file * @content_dir_fd: Open file descriptor for the content directory */ int cs_init(struct clip_store *cs, int snip_fd, int content_dir_fd) { cs->ready = false; cs->snip_fd = snip_fd; cs->content_dir_fd = content_dir_fd; cs->refcount = 0; _drop_(cs_unref) struct ref_guard guard = cs_ref_no_update(cs); struct stat st; if (fstat(snip_fd, &st) < 0) { return negative_errno(); } if (st.st_size % CS_SNIP_SIZE != 0) { return -EINVAL; } size_t file_size = (size_t)st.st_size; if (file_size == 0) { file_size = CS_SNIP_SIZE; if (ftruncate(snip_fd, (off_t)file_size) < 0) { return negative_errno(); } } cs->header = mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, snip_fd, 0); if (cs->header == MAP_FAILED) { return negative_errno(); } int ret = cs_header_validate(cs, file_size); if (ret < 0) { munmap(cs->header, file_size); return ret; } cs->snips = (struct cs_snip *)(cs->header + 1); cs->local_nr_snips = cs->header->nr_snips; cs->local_nr_snips_alloc = cs->header->nr_snips_alloc; cs->mapped_file_size = file_size; cs->ready = true; (void)guard; // Old clang will complain guard is unused, despite cleanup return 0; } /** * Round up a number to the nearest multiple of a specified step. * * @n: The number to round up * @step: The step size to round up to */ static size_t round_up(size_t n, size_t step) { return ((n + step - 1) / step) * step; } /** * Adjust the size of the mmapped file used by the clip store to the specified * new size. It first attempts to resize the file using ftruncate(). If this * operation is successful, it then remaps the memory mapping to reflect the * new size of the file. * * WARNING: cs->snips and cs->header may move after cs_file_resize(), so * copied pointers from before invocation must not be used after calling this. * * @cs: The clip store to operate on * @new_nr_snips: The new number of snips in the snip file */ static int _must_use_ _nonnull_ cs_file_resize(struct clip_store *cs, size_t new_nr_snips) { bool grow = new_nr_snips >= cs->header->nr_snips; if (grow && new_nr_snips <= cs->header->nr_snips_alloc) { cs->header->nr_snips = new_nr_snips; return 0; } /* If this is a shrink, do it exactly: someone may be deleting sensitive * snips and so it's best to remove them immediately. Otherwise, batch * allocate. */ size_t new_nr_snips_alloc = grow ? round_up(new_nr_snips, CS_SNIP_ALLOC_BATCH) : new_nr_snips; size_t new_size = cs_file_size(new_nr_snips_alloc); if (ftruncate(cs->snip_fd, (off_t)new_size) < 0) { return negative_errno(); } if (grow) { size_t old_file_size = cs_file_size(cs->header->nr_snips_alloc); int ret = cs_remap(cs, cs->mapped_file_size, new_size); if (ret < 0) { expect(ftruncate(cs->snip_fd, (off_t)old_file_size) == 0); return ret; } } cs->header->nr_snips = cs->local_nr_snips = new_nr_snips; cs->header->nr_snips_alloc = cs->local_nr_snips_alloc = new_nr_snips_alloc; return 0; } /** * Update a clip store snip to contain the specified hash and line content. * * @snip: Pointer to the snip to modify * @hash: The new hash value for the snip * @line: The new line content for the snip * @nr_lines: The number of lines in the line content */ static void _nonnull_ cs_snip_update(struct cs_snip *snip, uint64_t hash, const char *line, uint64_t nr_lines) { snip->hash = hash; snip->doomed = false; snip->nr_lines = nr_lines; strncpy(snip->line, line, CS_SNIP_LINE_SIZE - 1); snip->line[CS_SNIP_LINE_SIZE - 1] = '\0'; } /** * Extracts the first non-empty line from a given text buffer and copies it to * the output buffer. Returns the total number of lines. A final line with no * newline is considered a line for accounting purposes. * * @text: The input text buffer * @out: The output buffer. Must be at least CS_SNIP_LINE_SIZE bytes */ size_t first_line(const char *text, char *out) { bool found = false; size_t nr_lines = 0; const char *cur = text; out[0] = '\0'; for (; *cur; cur++) { nr_lines += (*cur == '\n'); if (!found && *cur != '\n') { found = true; snprintf(out, CS_SNIP_LINE_SIZE, "%.*s", (int)strcspn(cur, "\n"), cur); } } return nr_lines + (found && *--cur != '\n'); } /** * Add a new snip consisting of a hash value and a line of text to the end of * the clip store. The snip file size is grown as necessary to accommodate the * new snip. * * @cs: The clip store to operate on * @hash: The hash value of the snip to add * @line: The line content of the snip to add * @nr_lines: The number of lines in the line content */ static int _must_use_ _nonnull_ cs_snip_add(struct clip_store *cs, uint64_t hash, const char *line, uint64_t nr_lines) { _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } int ret = cs_file_resize(cs, cs->header->nr_snips + 1); if (ret < 0) { return ret; } cs_snip_update(cs->snips + cs->header->nr_snips - 1, hash, line, nr_lines); return 0; } /** * Add content to the content directory using the hash as the filename. * * @cs: The clip store to operate on * @hash: The hash of the content to add * @content: The content to add to the file * @dupe_policy: If set to CS_DUPE_KEEP_LAST, will return with -EEXIST when * trying to insert duplicate entry. */ static int _must_use_ _nonnull_ cs_content_add(struct clip_store *cs, uint64_t hash, const char *content, enum cs_dupe_policy dupe_policy) { bool dupe = false; size_t content_len = strlen(content); char dir_path[CS_HASH_STR_MAX]; snprintf(dir_path, sizeof(dir_path), PRI_HASH, hash); int ret = mkdirat(cs->content_dir_fd, dir_path, 0700); if (ret < 0) { if (errno != EEXIST || dupe_policy == CS_DUPE_KEEP_LAST) { return negative_errno(); } dupe = true; } char base_file_path[PATH_MAX]; snprintf(base_file_path, sizeof(base_file_path), "%s/1", dir_path); if (dupe) { // This clip already exists, just create a link for refcounting struct stat st; if (fstatat(cs->content_dir_fd, base_file_path, &st, 0) < 0) { return negative_errno(); } if ((size_t)st.st_size != content_len) { // Extremely unlikely with FNV-1a 64-bit outside of artificial // scenarios, but never hurts to be careful... return -EFAULT; } size_t link_num = (size_t)st.st_nlink + 1; char linkpath[PATH_MAX]; snprintf(linkpath, sizeof(linkpath), "%s/%zu", dir_path, link_num); if (linkat(cs->content_dir_fd, base_file_path, cs->content_dir_fd, linkpath, 0) < 0) { return negative_errno(); } return 0; } // This is a new clip _drop_(close) int fd = openat(cs->content_dir_fd, base_file_path, O_WRONLY | O_CREAT | O_EXCL, 0600); if (fd < 0) { return negative_errno(); } const char *cur = content; size_t remaining = content_len; while (remaining > 0) { ssize_t written = write(fd, cur, remaining); if (written < 0) { return negative_errno(); } remaining -= (size_t)written; cur += written; } return 0; } /** * Clean up the backing mapped data and file descriptor for a `struct * cs_content` object. * * @content: The content to unmap */ static int cs_content_unmap(struct cs_content *content) { if (content && content->data) { close(content->fd); if (munmap(content->data, (size_t)content->size)) { return negative_errno(); } } return 0; } /** * _drop_ function for cs_content_unmap() * * @content: The content to unmap */ void drop_cs_content_unmap(struct cs_content *content) { int ret = cs_content_unmap(content); expect(ret == 0); } /** * Retrieve the content associated with a given hash from the content directory * and map it into memory. * * @cs: The clip store to operate on * @hash: The hash of the content to retrieve * @content: A pointer to a `struct cs_content` to populate. The caller must * call cs_content_unmap() when done to free it */ int cs_content_get(struct clip_store *cs, uint64_t hash, struct cs_content *content) { memset(content, '\0', sizeof(struct cs_content)); char filename[PATH_MAX]; snprintf(filename, sizeof(filename), PRI_HASH "/1", hash); _drop_(close) int fd = openat(cs->content_dir_fd, filename, O_RDONLY); if (fd < 0) { return negative_errno(); } struct stat st; if (fstat(fd, &st) < 0) { return negative_errno(); } char *data = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); if (data == MAP_FAILED) { return negative_errno(); } content->data = data; content->fd = fd; content->size = st.st_size; fd = -1; // Disarm the _drop_, this will be handled by cs_content_unmap() return 0; } /** * Move the entry with the specified hash to the newest slot. * * @cs: The clip store to operate on * @hash: The hash of the entry to move */ int cs_make_newest(struct clip_store *cs, uint64_t hash) { _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } for (int i = 0; i < (int)cs->local_nr_snips; ++i) { if (cs->snips[i].hash == hash) { struct cs_snip tmp = cs->snips[i]; memmove(cs->snips + i, cs->snips + i + 1, (cs->local_nr_snips - (i + 1)) * sizeof(*cs->snips)); cs->snips[cs->local_nr_snips - 1] = tmp; return 0; } } return -ENOENT; } static int _must_use_ _nonnull_ cs_content_remove(struct clip_store *cs, uint64_t hash); /** * Add a new content entry to the clip store and content directory. * * @cs: The clip store to operate on * @content: The content to add * @out_hash: Output for the generated hash, or NULL * @dupe_policy: Policy to use for duplicate entries */ int cs_add(struct clip_store *cs, const char *content, uint64_t *out_hash, enum cs_dupe_policy dupe_policy) { uint64_t hash = fnv1a_64_hash(content); char line[CS_SNIP_LINE_SIZE]; size_t nr_lines = first_line(content, line); if (out_hash) { *out_hash = hash; } _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } int ret = cs_content_add(cs, hash, content, dupe_policy); if (ret == -EEXIST && dupe_policy == CS_DUPE_KEEP_LAST) { return cs_make_newest(cs, hash); } if (ret) { return ret; } ret = cs_snip_add(cs, hash, line, nr_lines); if (ret) { int rm_ret = cs_content_remove(cs, hash); if (rm_ret) { warn("Failed to roll back leaked content for hash " PRI_HASH " after cs_snip_add failed (%s): %s\n", hash, strerror(-ret), strerror(-rm_ret)); return rm_ret; } } return ret; } /** * Iterate over the snips in the clip store. The function should be initially * called with *snip set to NULL, which will set *snip to point to the newest * snip (excluding the header). On subsequent calls, *snip is updated to point * to the next snip in the snip file. The iteration stops when there are no * more snips to process, indicated by the function returning false. * * @guard: The guard lock * @snip: Pointer to a pointer to the current snip being iterated over * @direction: Whether to iterate from the oldest to newest or vice versa */ bool cs_snip_iter(struct ref_guard *guard, enum cs_iter_direction direction, struct cs_snip **snip) { if (guard->status < 0 || guard->cs->header->nr_snips == 0) { return false; } struct cs_snip *oldest = guard->cs->snips; struct cs_snip *newest = oldest + (guard->cs->header->nr_snips - 1); const struct cs_snip *stop = direction == CS_ITER_NEWEST_FIRST ? oldest : newest; if (!*snip) { *snip = direction == CS_ITER_NEWEST_FIRST ? newest : oldest; return true; } else if (*snip != stop) { *snip = *snip + (direction == CS_ITER_NEWEST_FIRST ? -1 : 1); return true; } return false; } /** * Remove content from the content directory using the hash as the filename. * * @cs: The clip store to operate on * @hash: The hash of the content to remove */ static int _must_use_ _nonnull_ cs_content_remove(struct clip_store *cs, uint64_t hash) { char hash_dir_name[CS_HASH_STR_MAX]; snprintf(hash_dir_name, sizeof(hash_dir_name), PRI_HASH, hash); _drop_(close) int hash_dir_fd = openat(cs->content_dir_fd, hash_dir_name, O_RDONLY); if (hash_dir_fd < 0) { return negative_errno(); } struct stat st; if (fstatat(hash_dir_fd, "1", &st, 0) < 0) { return negative_errno(); } char nlink_path[PATH_MAX]; snprintf(nlink_path, sizeof(nlink_path), "%u", (unsigned)st.st_nlink); if (unlinkat(hash_dir_fd, nlink_path, 0) < 0) { return negative_errno(); } if (st.st_nlink == 1 && unlinkat(cs->content_dir_fd, hash_dir_name, AT_REMOVEDIR) < 0) { return negative_errno(); } return 0; } /** * Compacts the clip store by removing doomed snips, finalising their removal * after being marked in cs_remove(). * * @guard: The guard lock */ static size_t _nonnull_ cs_snip_remove_doomed(struct ref_guard *guard) { size_t nr_doomed = 0; struct cs_snip *snip = NULL; while (cs_snip_iter(guard, CS_ITER_OLDEST_FIRST, &snip)) { if (snip->doomed) { nr_doomed++; } else if (nr_doomed > 0) { *(snip - nr_doomed) = *snip; } } return nr_doomed; } /** * Iterate over the specified number of snips in the clip store from newest * to oldest and remove those for which the predicate function returns * CS_ACTION_REMOVE (see `enum cs_remove_action` above). * * @cs: The clip store to operate on * @should_remove: Function pointer to the predicate used to decide removal * @private: Pointer to user-defined data passed to the predicate function * @direction: Whether to iterate from the oldest to newest or vice versa */ int cs_remove(struct clip_store *cs, enum cs_iter_direction direction, enum cs_remove_action (*should_remove)(uint64_t, const char *, void *), void *private) { _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } bool found = false; struct cs_snip *snip = NULL; while (cs_snip_iter(&guard, direction, &snip)) { enum cs_remove_action action = should_remove(snip->hash, snip->line, private); if (action & CS_ACTION_REMOVE) { found = true; int ret = cs_content_remove(cs, snip->hash); if (ret < 0) { return ret; } snip->doomed = true; } if (action & CS_ACTION_STOP) { break; } } if (!found) { return 0; } size_t nr_doomed = cs_snip_remove_doomed(&guard); int ret = cs_file_resize(cs, cs->header->nr_snips - nr_doomed); if (ret < 0) { return ret; } return 0; } /** * Callback function for deciding which snips to remove during trim operation. * * @hash: Unused * @line: Unused * @private: The count of remaining entries to trim */ static enum cs_remove_action _must_use_ _nonnull_ trim_callback(uint64_t hash, const char *line, void *private) { (void)hash; (void)line; size_t *count = private; if (*count == 0) { return CS_ACTION_REMOVE; } (*count)--; return CS_ACTION_KEEP; } /** * Trim the clip store to only retain the specified number of snips. * * @cs: The clip store to operate on * @direction: Whether to remove the N newest or N oldest * @nr_keep: The number of newest snips to retain */ int cs_trim(struct clip_store *cs, enum cs_iter_direction direction, size_t nr_keep) { if (nr_keep >= cs->header->nr_snips) { return 0; // No action needed if we're keeping everything or more } int ret = cs_remove(cs, direction, trim_callback, &nr_keep); if (ret < 0) { return ret; } return 0; } /** * Replace the content and snip for an entry in the clip store, identified by * its age. * * @cs: The clip store to operate on * @age: The age of the snip to replace, with 0 being the newest * @direction: Whether to iterate from the oldest to newest or vice versa * @content: The content to replace this entry with * @out_hash: Output for the generated hash, or NULL */ int cs_replace(struct clip_store *cs, enum cs_iter_direction direction, size_t age, const char *content, uint64_t *out_hash) { _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } if (age >= cs->header->nr_snips) { return -ERANGE; } size_t idx = direction == CS_ITER_NEWEST_FIRST ? cs->header->nr_snips - age - 1 : age; struct cs_snip *snip = cs->snips + idx; char line[CS_SNIP_LINE_SIZE]; size_t nr_lines = first_line(content, line); uint64_t old_hash = snip->hash; uint64_t hash = fnv1a_64_hash(content); int ret = cs_content_add(cs, hash, content, CS_DUPE_KEEP_ALL); if (ret) { return ret; } ret = cs_content_remove(cs, old_hash); if (ret) { return ret; } cs_snip_update(snip, hash, line, nr_lines); if (out_hash) { *out_hash = hash; } return 0; } /** * Get the current number of entries in the clip store. * * @cs: The clip store to operate on * @out_len: Output for the length of the clip store */ int cs_len(struct clip_store *cs, size_t *out_len) { _drop_(cs_unref) struct ref_guard guard = cs_ref(cs); if (guard.status < 0) { return guard.status; } *out_len = cs->header->nr_snips; return 0; }