From b3a6c3da50416b36d9e8f1756a1fb1b173ed0037 Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Sat, 15 Aug 2026 16:34:36 -0400 Subject: [PATCH] Added --simulate-json option to write the simulated change set as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changes computed by a --simulate run could only be rendered as prose meant for human eyes: the manifest and diff renderers print free-form text, and the record files in the changes chroot are an internal, unstable format. Any program consuming the simulated change set had to parse text that can be reworded at any time. With --simulate-json=FILE, cf-agent also writes the change set to FILE as a single JSON document: which files would be created, modified or deleted (with the type, permissions, ownership, size and SHA-256 content digest they would have after the run), which files would be renamed, and which packages would be installed or removed. The document carries a format_version field so that consumers can detect future changes to its structure. The option requires --simulate and an absolute path. The prose renderers remain the default output and their file-level output is unchanged. The package-operation renderers are not: the diff renderer now shares its reduction with the JSON output (see below), and both the diff and manifest renderers now correctly cancel a previously recorded removal when a later installation of the same package is recorded. Before, such a sequence was reported as both operations, because the cancellation was applied to a key that had already been handed over to the map. The reduction of the recorded package operations to the net set of install and remove operations is shared with the --simulate=diff prose renderer: DiffPkgOperations() now renders its messages from the reduced records at printing time instead of storing pre-rendered messages in them. Path names are written as raw UTF-8 bytes wherever they form valid UTF-8. JsonWrite() escapes every non-ASCII byte as an individual "\u00XX" sequence, which is well-formed JSON but denotes the code point U+00XX, so a conformant parser decodes each byte of a multi-byte character as a separate wrong character -- a file name like "café" would not survive the round trip. Bytes that are not part of a valid UTF-8 sequence stay escaped, since a JSON document has to be valid UTF-8 itself and there is no exact representation for them. The underlying escaping is libntech's and would be better fixed there; this is deliberately kept local to the change set writer so that the output of every other JsonWrite() caller stays as it is. Ownership is written as a 64-bit integer, so that a uid or gid that does not fit in an int -- 4294967294, the usual "nobody" on Linux -- is not reported as -2. The document is written to a temporary file created with O_EXCL, which is then renamed over the destination. An existing file is therefore not truncated before the new content is known to be complete, and a symlink at the destination is replaced rather than followed. A failed write is reported: cf-agent exits non-zero instead of leaving a consumer to believe in a document that was never written. The SHA-256 field is omitted, with an error logged, when the digest could not be computed -- HashFile() cannot report failure and zeroes the digest instead, and a zeroed digest presented as a real one is worse than an absent field. The document is written before GenericAgentFinalize() because computing the content digests needs the crypto (OpenSSL) library, which is deinitialized there. Changelog: Title Ticket: CFE-4716 --- cf-agent/cf-agent.c | 50 ++ cf-agent/simulate_mode.c | 730 +++++++++++++++++- cf-agent/simulate_mode.h | 2 + .../29_simulate_mode/simulate_json.cf | 65 ++ .../simulate_json.cf.expected | 158 ++++ tests/unit/Makefile.am | 3 + tests/unit/simulate_mode_test.c | 578 ++++++++++++++ 7 files changed, 1549 insertions(+), 37 deletions(-) create mode 100644 tests/acceptance/29_simulate_mode/simulate_json.cf create mode 100644 tests/acceptance/29_simulate_mode/simulate_json.cf.expected create mode 100644 tests/unit/simulate_mode_test.c diff --git a/cf-agent/cf-agent.c b/cf-agent/cf-agent.c index 035da48af0..2755f8014b 100644 --- a/cf-agent/cf-agent.c +++ b/cf-agent/cf-agent.c @@ -112,6 +112,7 @@ static bool ALLCLASSESREPORT = false; /* GLOBAL_P */ static bool ALWAYS_VALIDATE = false; /* GLOBAL_P */ static bool CFPARANOID = false; /* GLOBAL_P */ static bool PERFORM_DB_CHECK = false; +static char *SIMULATE_JSON_FILE = NULL; static const Rlist *ACCESSLIST = NULL; /* GLOBAL_P */ @@ -221,6 +222,7 @@ static const struct option OPTIONS[] = {"skip-bootstrap-service-start", no_argument, 0, 0 }, {"skip-db-check", optional_argument, 0, 0 }, {"simulate", required_argument, 0, 0}, + {"simulate-json", required_argument, 0, 0}, {NULL, 0, 0, '\0'} }; @@ -257,6 +259,7 @@ static const char *const HINTS[] = "Do not start CFEngine services as part of the bootstrap process", "Do not run database integrity checks and repairs at startup", "Run in simulate mode, either 'manifest', 'manifest-full' or 'diff'", + "Write the change set of a --simulate run to the given file as JSON", NULL }; @@ -385,6 +388,26 @@ int main(int argc, char *argv[]) Nova_NoteAgentExecutionPerformance(config->input_file, start); + /* The simulated change set includes digests of file contents, so it has + * to be written before GenericAgentFinalize() deinitializes the crypto + * (OpenSSL) library. */ + if (SIMULATE_JSON_FILE != NULL) + { + if (!WriteChangesJson(SIMULATE_JSON_FILE)) + { + Log(LOG_LEVEL_ERR, + "Failed to write the simulated change set to '%s'", + SIMULATE_JSON_FILE); + + /* A consumer of the change set must not see a successful run + * without the document it asked for. */ + if (ret == 0) + { + ret = EXIT_FAILURE; + } + } + } + GenericAgentFinalize(ctx, config); StringSetDestroy(SINGLE_COPY_CACHE); @@ -795,6 +818,26 @@ static GenericAgentConfig *CheckOpts(int argc, char **argv) DoCleanupAndExit(EXIT_FAILURE); } } + else if (StringEqual(option_name, "simulate-json")) + { + if (optarg == NULL) + { + Log(LOG_LEVEL_ERR, + "Missing argument for --simulate-json, a file path required"); + DoCleanupAndExit(EXIT_FAILURE); + } + else if (!IsAbsPath(optarg)) + { + Log(LOG_LEVEL_ERR, + "Invalid argument for --simulate-json, an absolute path required, not '%s'", + optarg); + DoCleanupAndExit(EXIT_FAILURE); + } + else + { + SIMULATE_JSON_FILE = xstrdup(optarg); + } + } break; } default: @@ -822,6 +865,13 @@ static GenericAgentConfig *CheckOpts(int argc, char **argv) DoCleanupAndExit(EXIT_FAILURE); } + if ((SIMULATE_JSON_FILE != NULL) && !ChrootChanges()) + { + Log(LOG_LEVEL_ERR, + "Option --simulate-json can only be used together with --simulate"); + DoCleanupAndExit(EXIT_FAILURE); + } + FreeFixedStringArray(argc_new, argv_new); return config; diff --git a/cf-agent/simulate_mode.c b/cf-agent/simulate_mode.c index 2512617d21..b25e4ddfa4 100644 --- a/cf-agent/simulate_mode.c +++ b/cf-agent/simulate_mode.c @@ -41,6 +41,10 @@ #include /* StringMap */ #include /* GetCsvLineNext() */ #include /* GetGroupName(), GetUserName() */ +#include /* JsonElement */ +#include /* StringWriter() */ +#include /* HashFile(), HashPrintSafe() */ +#include /* CF_PERMS_DEFAULT */ #include @@ -669,6 +673,33 @@ static void PkgOperationRecordDestroy(PkgOperationRecord *pkg_op) } } +typedef struct PkgOperation_ { + char *name; + char *arch; + char *version; +} PkgOperation; + +static PkgOperation *PkgOperationNew(char *name, char *arch, char *version) +{ + PkgOperation *ret = xmalloc(sizeof(PkgOperation)); + ret->name = name; + ret->arch = arch; + ret->version = version; + + return ret; +} + +static void PkgOperationDestroy(PkgOperation *pkg_op) +{ + if (pkg_op != NULL) + { + free(pkg_op->name); + free(pkg_op->arch); + free(pkg_op->version); + free(pkg_op); + } +} + static inline bool PkgVersionIsGreater(const char *ver1, const char *ver2) { /* Empty/missing versions should be handled separately based on the @@ -727,12 +758,22 @@ static inline char *GetPkgOperationMsg(ChrootPkgOperationCode op, const char *pk return msg; } -bool DiffPkgOperations() +/* Reduce the package operations recorded during the agent run to the net set + * of packages that would be installed and removed. On success, either both + * #installed_out and #removed_out are set to maps of PkgOperation items keyed + * by package name and architecture, or both are set to NULL if no package + * operations were recorded during the run. */ +static bool CollectPkgOperations(Map **installed_out, Map **removed_out) { + assert(installed_out != NULL); + assert(removed_out != NULL); + + *installed_out = NULL; + *removed_out = NULL; + const char *pkgs_ops_csv_file = ToChangesChroot(CHROOT_PKGS_OPS_FILE); if (access(pkgs_ops_csv_file, F_OK) != 0) { - Log(LOG_LEVEL_INFO, "No package operations done by the agent run"); return true; } @@ -744,8 +785,8 @@ bool DiffPkgOperations() return false; } - Map *installed = MapNew(StringHash_untyped, StringEqual_untyped, free, (MapDestroyDataFn) PkgOperationRecordDestroy); - Map *removed = MapNew(StringHash_untyped, StringEqual_untyped, free, (MapDestroyDataFn) PkgOperationRecordDestroy); + Map *installed = MapNew(StringHash_untyped, StringEqual_untyped, free, (MapDestroyDataFn) PkgOperationDestroy); + Map *removed = MapNew(StringHash_untyped, StringEqual_untyped, free, (MapDestroyDataFn) PkgOperationDestroy); char *line; while ((line = GetCsvLineNext(csv_file)) != NULL) { @@ -785,7 +826,7 @@ bool DiffPkgOperations() * and so the package is still seen as present in the system (package cache). * * This means that a 'present' operation after 'remove' operation results in no - * difference (the package would be installed back) so the potential message about the + * difference (the package would be installed back) so the potential record about the * removal should be removed. */ MapRemove(removed, name_arch); } @@ -793,7 +834,7 @@ bool DiffPkgOperations() { /* The same logic as above applies here for an originally absent package that is * installed and then reported as absent again. No diff to report, just remove the - * message about the package installation. */ + * record about the package installation. */ /* However, if a different specific version is reported as absent than the version that * would have been installed, this removal would not remove the installed package @@ -804,8 +845,8 @@ bool DiffPkgOperations() } else { - PkgOperationRecord *record = MapGet(installed, name_arch); - if ((record != NULL) && StringEqual(pkg_ver, record->pkg_ver)) + PkgOperation *pkg_op = MapGet(installed, name_arch); + if ((pkg_op != NULL) && StringEqual(pkg_ver, pkg_op->version)) { /* Matching version being removed -> cancel the installation */ MapRemove(installed, name_arch); @@ -823,28 +864,28 @@ bool DiffPkgOperations() * operation. So 'install' operation must mean a newer version than what's present would * be installed. */ - PkgOperationRecord *prev_record = MapGet(installed, name_arch); - if ((prev_record == NULL) || PkgVersionIsGreater(pkg_ver, prev_record->pkg_ver)) + /* Package installation cancels a previous removal (if any). */ + MapRemove(removed, name_arch); + + PkgOperation *prev_op = MapGet(installed, name_arch); + if ((prev_op == NULL) || PkgVersionIsGreater(pkg_ver, prev_op->version)) { - char *msg = GetPkgOperationMsg(CHROOT_PKG_OPERATION_CODE_INSTALL, - pkg_name, pkg_arch, pkg_ver); - PkgOperationRecord *record = PkgOperationRecordNew(msg, SafeStringDuplicate(pkg_ver)); - MapInsert(installed, name_arch, record); + PkgOperation *pkg_op = PkgOperationNew(SafeStringDuplicate(pkg_name), + SafeStringDuplicate(pkg_arch), + SafeStringDuplicate(pkg_ver)); + MapInsert(installed, name_arch, pkg_op); name_arch = NULL; /* name_arch is now owned by the map (as a key) */ } - - /* Package installation cancels a previous removal (if any). */ - MapRemove(removed, name_arch); } else { assert(*op == CHROOT_PKG_OPERATION_CODE_REMOVE); /* The only option not covered above. */ /* If there is a previous 'remove' operation record with version specified, prefer that - * message over a new message without version specification as the net result would be + * record over a new record without version specification as the net result would be * the package being removed, in the version that was pressent. */ - PkgOperationRecord *prev_record = MapGet(removed, name_arch); - bool insert_new_msg = ((prev_record == NULL) || (NULL_OR_EMPTY(prev_record->pkg_ver))); + PkgOperation *prev_op = MapGet(removed, name_arch); + bool insert_new_record = ((prev_op == NULL) || (NULL_OR_EMPTY(prev_op->version))); /* If there is a previous 'install' operation and now there is a 'remove' operation it * means that the package was initially present, then updated by the 'install' operation @@ -855,29 +896,29 @@ bool DiffPkgOperations() * would remove the installed package. */ if (NULL_OR_EMPTY(pkg_ver)) { - /* No version specified, remove the installation message (if any). */ + /* No version specified, remove the installation record (if any). */ MapRemove(installed, name_arch); } else { - PkgOperationRecord *inst_record = MapGet(installed, name_arch); - if ((inst_record != NULL) && (StringEqual(pkg_ver, inst_record->pkg_ver))) + PkgOperation *inst_op = MapGet(installed, name_arch); + if ((inst_op != NULL) && (StringEqual(pkg_ver, inst_op->version))) { MapRemove(installed, name_arch); } else { - /* Keeping the install message, the removal would make no change. */ - insert_new_msg = false; + /* Keeping the install record, the removal would make no change. */ + insert_new_record = false; } } - if (insert_new_msg) + if (insert_new_record) { - char *msg = GetPkgOperationMsg(CHROOT_PKG_OPERATION_CODE_REMOVE, - pkg_name, pkg_arch, pkg_ver); - PkgOperationRecord *record = PkgOperationRecordNew(msg, SafeStringDuplicate(pkg_ver)); - MapInsert(removed, name_arch, record); + PkgOperation *pkg_op = PkgOperationNew(SafeStringDuplicate(pkg_name), + SafeStringDuplicate(pkg_arch), + SafeStringDuplicate(pkg_ver)); + MapInsert(removed, name_arch, pkg_op); name_arch = NULL; /* name_arch is now owned by the map (as a key) */ } } @@ -886,6 +927,28 @@ bool DiffPkgOperations() } fclose(csv_file); + *installed_out = installed; + *removed_out = removed; + + return true; +} + +bool DiffPkgOperations() +{ + Map *installed = NULL; + Map *removed = NULL; + if (!CollectPkgOperations(&installed, &removed)) + { + return false; + } + + if (installed == NULL) + { + assert(removed == NULL); + Log(LOG_LEVEL_INFO, "No package operations done by the agent run"); + return true; + } + if ((MapSize(installed) == 0) && (MapSize(removed) == 0)) { Log(LOG_LEVEL_INFO, "No differences in installed packages to report"); @@ -901,16 +964,20 @@ bool DiffPkgOperations() MapKeyValue *item; while ((item = MapIteratorNext(&i))) { - PkgOperationRecord *value = item->value; - const char *msg = value->msg; + const PkgOperation *pkg_op = item->value; + char *msg = GetPkgOperationMsg(CHROOT_PKG_OPERATION_CODE_INSTALL, + pkg_op->name, pkg_op->arch, pkg_op->version); puts(msg); + free(msg); } i = MapIteratorInit(removed); while ((item = MapIteratorNext(&i))) { - PkgOperationRecord *value = item->value; - const char *msg = value->msg; + const PkgOperation *pkg_op = item->value; + char *msg = GetPkgOperationMsg(CHROOT_PKG_OPERATION_CODE_REMOVE, + pkg_op->name, pkg_op->arch, pkg_op->version); puts(msg); + free(msg); } MapDestroy(installed); @@ -970,6 +1037,9 @@ bool ManifestPkgOperations() if ((*op == CHROOT_PKG_OPERATION_CODE_INSTALL) || (*op == CHROOT_PKG_OPERATION_CODE_PRESENT)) { + /* Cancels any previous remove/absent message. */ + MapRemove(absent, name_arch); + /* If there is a previous install/present operation, we want to choose the message with * the higher version or the message which has a specific version (if any). */ PkgOperationRecord *prev_record = MapGet(present, name_arch); @@ -984,9 +1054,6 @@ bool ManifestPkgOperations() MapInsert(present, name_arch, record); name_arch = NULL; /* name_arch is now owned by the map (as a key) */ } - - /* Cancels any previous remove/absent message. */ - MapRemove(absent, name_arch); } else { @@ -1050,3 +1117,592 @@ bool ManifestPkgOperations() return true; } + + +/* Version of the JSON change set document written by WriteChangesJson(). Must + * be incremented whenever the structure or the semantics of the document + * change in a way that existing consumers cannot safely ignore. */ +#define CHANGES_JSON_FORMAT_VERSION 1 + +static JsonElement *ChangedFileAsJson(const char *path) +{ + assert(path != NULL); + + JsonElement *file_info = JsonObjectCreate(8); + JsonObjectAppendString(file_info, "path", path); + + const char *chrooted_path = ToChangesChroot(path); + struct stat st; + if (lstat(chrooted_path, &st) == -1) + { + /* Deleted by the run (or created and deleted again, in which case the + * file doesn't exist before the run either). */ + JsonObjectAppendString(file_info, "change", "deleted"); + return file_info; + } + + struct stat st_orig; + if (lstat(path, &st_orig) == -1) + { + JsonObjectAppendString(file_info, "change", "created"); + } + else + { + JsonObjectAppendString(file_info, "change", "modified"); + } + + /* All the information below describes the file as it would be after the + * run (the file in the changes chroot). */ + JsonObjectAppendString(file_info, "type", + GetFileTypeDescription(st.st_mode)); + + char perms[5]; + xsnprintf(perms, sizeof(perms), "%04jo", + (uintmax_t) (st.st_mode & CHMOD_MODE_BITS)); + JsonObjectAppendString(file_info, "permissions", perms); + + /* uid_t and gid_t are unsigned, so an id above INT_MAX (e.g. the common + * 'nobody' uid 4294967294) needs the 64-bit writer to not turn + * negative. */ + JsonObjectAppendInteger64(file_info, "uid", (int64_t) st.st_uid); + JsonObjectAppendInteger64(file_info, "gid", (int64_t) st.st_gid); + + if (S_ISREG(st.st_mode)) + { + JsonObjectAppendInteger64(file_info, "size", (int64_t) st.st_size); + + unsigned char digest[EVP_MAX_MD_SIZE + 1] = {0}; + HashFile(chrooted_path, digest, HASH_METHOD_SHA256, false); + + /* HashFile() cannot report a failure, but it leaves the digest + * zeroed when one occurs and a real SHA-256 digest is never all + * zeros. Omit the field rather than presenting zeros as a real + * digest. */ + const size_t digest_len = HashSizeFromId(HASH_METHOD_SHA256); + bool have_digest = false; + for (size_t i = 0; !have_digest && (i < digest_len); i++) + { + have_digest = (digest[i] != 0); + } + if (have_digest) + { + char digest_str[CF_HOSTKEY_STRING_SIZE]; + HashPrintSafe(digest_str, sizeof(digest_str), digest, + HASH_METHOD_SHA256, false); + JsonObjectAppendString(file_info, "sha256", digest_str); + } + else + { + Log(LOG_LEVEL_ERR, + "Failed to compute the SHA-256 digest of '%s'", + chrooted_path); + } + } +#ifndef __MINGW32__ + else if (S_ISLNK(st.st_mode)) + { + char target[PATH_MAX] = {0}; + ssize_t target_len = readlink(chrooted_path, target, + sizeof(target) - 1); + + /* A target of sizeof(target) - 1 bytes or more would be truncated + * and presented as a wrong (but plausible) path, better to omit it + * than to report it wrong. */ + if ((target_len > 0) && ((size_t) target_len < (sizeof(target) - 1))) + { + const char *real_target = target; + if (IsAbsoluteFileName(target)) + { + real_target = ToNormalRoot(target); + } + JsonObjectAppendString(file_info, "target", real_target); + } + } +#endif /* !__MINGW32__ */ + + return file_info; +} + +static bool AddChangedFilesToJson(JsonElement *files) +{ + assert(files != NULL); + + const char *files_list_file = ToChangesChroot(CHROOT_CHANGES_LIST_FILE); + + /* If the file doesn't exist, there were no changes recorded. */ + if (access(files_list_file, F_OK) != 0) + { + return true; + } + + int fd = safe_open(files_list_file, O_RDONLY); + if (fd == -1) + { + Log(LOG_LEVEL_ERR, + "Failed to open the file with list of changed files: %s", + GetErrorStr()); + return false; + } + + StringSet *recorded_files = StringSetNew(); + bool success = true; + bool done = false; + while (!done) + { + char *path; + int ret = ReadLenPrefixedString(fd, &path); + if (ret > 0) + { + /* Each file should only be reported once. */ + if (!StringSetContains(recorded_files, path)) + { + JsonArrayAppendObject(files, ChangedFileAsJson(path)); + + /* The set takes ownership of path. */ + StringSetAdd(recorded_files, path); + } + else + { + free(path); + } + } + else if (ret == 0) + { + /* EOF */ + done = true; + } + else + { + Log(LOG_LEVEL_ERR, "Failed to read the list of changed files"); + success = false; + done = true; + } + } + close(fd); + StringSetDestroy(recorded_files); + return success; +} + +static bool AddRenamedFilesToJson(JsonElement *renames) +{ + assert(renames != NULL); + + const char *renamed_files_file = ToChangesChroot(CHROOT_RENAMES_LIST_FILE); + + /* If the file doesn't exist, there were no renames recorded. */ + if (access(renamed_files_file, F_OK) != 0) + { + return true; + } + + int fd = safe_open(renamed_files_file, O_RDONLY); + if (fd == -1) + { + Log(LOG_LEVEL_ERR, + "Failed to open the file with list of renamed files: %s", + GetErrorStr()); + return false; + } + + bool success = true; + bool done = false; + while (!done) + { + /* The CHROOT_RENAMES_LIST_FILE contains lines where two consecutive + * lines represent the original and the new name of a file (see + * RecordFileRenamedInChroot(). */ + char *orig_name; + int ret = ReadLenPrefixedString(fd, &orig_name); + if (ret > 0) + { + char *new_name; + ret = ReadLenPrefixedString(fd, &new_name); + if (ret > 0) + { + JsonElement *rename = JsonObjectCreate(2); + JsonObjectAppendString(rename, "old_name", orig_name); + JsonObjectAppendString(rename, "new_name", new_name); + JsonArrayAppendObject(renames, rename); + free(new_name); + } + else + { + /* If there was the line with the original name, there + * must be a line with the new name. */ + Log(LOG_LEVEL_ERR, "Invalid data about renamed files"); + success = false; + done = true; + } + free(orig_name); + } + else if (ret == 0) + { + /* EOF */ + done = true; + } + else + { + Log(LOG_LEVEL_ERR, "Failed to read the list of renamed files"); + success = false; + done = true; + } + } + close(fd); + return success; +} + +static void JsonArrayAppendPkgOperations(JsonElement *packages, Map *pkg_ops, + const char *operation) +{ + assert(packages != NULL); + assert(pkg_ops != NULL); + + MapIterator i = MapIteratorInit(pkg_ops); + MapKeyValue *item; + while ((item = MapIteratorNext(&i))) + { + const PkgOperation *pkg_op = item->value; + JsonElement *op_info = JsonObjectCreate(4); + JsonObjectAppendString(op_info, "operation", operation); + JsonObjectAppendString(op_info, "name", pkg_op->name); + if (!NULL_OR_EMPTY(pkg_op->arch)) + { + JsonObjectAppendString(op_info, "architecture", pkg_op->arch); + } + if (!NULL_OR_EMPTY(pkg_op->version)) + { + JsonObjectAppendString(op_info, "version", pkg_op->version); + } + JsonArrayAppendObject(packages, op_info); + } +} + +static bool AddPkgOperationsToJson(JsonElement *packages) +{ + assert(packages != NULL); + + Map *installed = NULL; + Map *removed = NULL; + if (!CollectPkgOperations(&installed, &removed)) + { + return false; + } + + if (installed == NULL) + { + /* No package operations recorded. */ + assert(removed == NULL); + return true; + } + + JsonArrayAppendPkgOperations(packages, installed, "install"); + JsonArrayAppendPkgOperations(packages, removed, "remove"); + + MapDestroy(installed); + MapDestroy(removed); + + return true; +} + +/* Returns the length (2 to 4) of the valid multi-byte UTF-8 sequence at the + * beginning of the #n bytes at #bytes, or 0 if they do not start with one + * (overlong encodings, surrogates and code points above U+10FFFF are + * invalid). */ +static size_t ValidUtf8SequenceLength(const unsigned char *bytes, size_t n) +{ + assert(bytes != NULL); + + if (n < 2) + { + return 0; + } + + /* The ranges of the lead byte and the first continuation byte, see RFC + * 3629. */ + size_t len; + unsigned char cont_min = 0x80; + unsigned char cont_max = 0xbf; + if ((bytes[0] >= 0xc2) && (bytes[0] <= 0xdf)) + { + len = 2; + } + else if ((bytes[0] >= 0xe0) && (bytes[0] <= 0xef)) + { + len = 3; + if (bytes[0] == 0xe0) + { + cont_min = 0xa0; /* overlong otherwise */ + } + else if (bytes[0] == 0xed) + { + cont_max = 0x9f; /* surrogate otherwise */ + } + } + else if ((bytes[0] >= 0xf0) && (bytes[0] <= 0xf4)) + { + len = 4; + if (bytes[0] == 0xf0) + { + cont_min = 0x90; /* overlong otherwise */ + } + else if (bytes[0] == 0xf4) + { + cont_max = 0x8f; /* above U+10FFFF otherwise */ + } + } + else + { + /* A continuation byte, an overlong lead byte (0xc0, 0xc1) or a byte + * that cannot appear in UTF-8 at all. */ + return 0; + } + + if (n < len) + { + return 0; + } + if ((bytes[1] < cont_min) || (bytes[1] > cont_max)) + { + return 0; + } + for (size_t i = 2; i < len; i++) + { + if ((bytes[i] < 0x80) || (bytes[i] > 0xbf)) + { + return 0; + } + } + return len; +} + +/* If #c points at a "\uXXXX" escape sequence encoding a single byte (XXXX <= + * 0x00FF, the only form JsonEncodeStringWriter() produces), stores the byte + * in #byte_out and returns true, otherwise returns false. */ +static bool GetJsonEscapedByte(const char *c, unsigned char *byte_out) +{ + assert(c != NULL); + assert(byte_out != NULL); + + if ((c[0] != '\\') || (c[1] != 'u')) + { + return false; + } + + unsigned int value = 0; + for (size_t i = 2; i < 6; i++) + { + const char h = c[i]; + value <<= 4; + if ((h >= '0') && (h <= '9')) + { + value |= (h - '0'); + } + else if ((h >= 'a') && (h <= 'f')) + { + value |= (h - 'a' + 10); + } + else if ((h >= 'A') && (h <= 'F')) + { + value |= (h - 'A' + 10); + } + else + { + return false; + } + } + if (value > 0xff) + { + return false; + } + + *byte_out = value; + return true; +} + +/* JsonWrite() (JsonEncodeStringWriter() in libntech) escapes every byte + * outside printable ASCII as an individual "\u00XX" escape sequence. That is + * well-formed JSON, but "\u00XX" denotes the code point U+00XX, so a + * conformant JSON parser decodes each byte of a multi-byte UTF-8 character + * (e.g. in a file name) as a separate, wrong character. Since the change set + * is meant for consumption by other programs, rewrite the escapes that + * encode a valid UTF-8 sequence back to the raw bytes, which a JSON string + * can carry verbatim. Escaped bytes that are not part of a valid UTF-8 + * sequence are left as "\u00XX" -- there is no way to represent them exactly + * in a JSON document, which has to be valid UTF-8 itself. Returns the + * rewritten copy of #json_str. */ +static char *RestoreUtf8InJson(const char *json_str) +{ + assert(json_str != NULL); + + Writer *writer = StringWriter(); + const char *c = json_str; + while (*c != '\0') + { + /* Everything JsonWrite() emits is ASCII and every backslash starts + * an escape sequence inside a string. Collect up to 4 consecutive + * escapes of non-ASCII bytes -- the longest possible UTF-8 + * sequence. */ + unsigned char bytes[4]; + size_t n_bytes = 0; + unsigned char byte; + while ((n_bytes < 4) && + GetJsonEscapedByte(c + (6 * n_bytes), &byte) && + (byte >= 0x80)) + { + bytes[n_bytes] = byte; + n_bytes++; + } + + if (n_bytes == 0) + { + WriterWriteChar(writer, *c); + c++; + if ((c[-1] == '\\') && (*c != '\0')) + { + /* Copy the escaped character too so that an escaped + * backslash ("\\") is not mistaken for the start of a new + * escape sequence. */ + WriterWriteChar(writer, *c); + c++; + } + } + else + { + const size_t seq_len = ValidUtf8SequenceLength(bytes, n_bytes); + if (seq_len > 0) + { + for (size_t i = 0; i < seq_len; i++) + { + WriterWriteChar(writer, bytes[i]); + } + c += 6 * seq_len; + } + else + { + /* Not (the start of) a valid UTF-8 sequence, keep the first + * escape and reconsider the rest in the next iteration. */ + for (size_t i = 0; i < 6; i++) + { + WriterWriteChar(writer, c[i]); + } + c += 6; + } + } + } + return StringWriterClose(writer); +} + +bool WriteChangesJson(const char *output_file) +{ + assert(output_file != NULL); + + const char *mode_str; + switch (EVAL_MODE) + { + case EVAL_MODE_SIMULATE_MANIFEST: + mode_str = "manifest"; + break; + case EVAL_MODE_SIMULATE_MANIFEST_FULL: + mode_str = "manifest-full"; + break; + case EVAL_MODE_SIMULATE_DIFF: + mode_str = "diff"; + break; + default: + debug_abort_if_reached(); + mode_str = "unknown"; + break; + } + + JsonElement *json = JsonObjectCreate(5); + JsonObjectAppendInteger(json, "format_version", + CHANGES_JSON_FORMAT_VERSION); + JsonObjectAppendString(json, "simulate_mode", mode_str); + + JsonElement *files = JsonArrayCreate(10); + bool success = AddChangedFilesToJson(files); + JsonObjectAppendArray(json, "files", files); + + JsonElement *renames = JsonArrayCreate(10); + success = success && AddRenamedFilesToJson(renames); + JsonObjectAppendArray(json, "renames", renames); + + JsonElement *packages = JsonArrayCreate(10); + success = success && AddPkgOperationsToJson(packages); + JsonObjectAppendArray(json, "packages", packages); + + if (!success) + { + /* An incomplete document could be mistaken for the complete change + * set, better to not produce any output at all. */ + JsonDestroy(json); + return false; + } + + Log(LOG_LEVEL_INFO, "Writing the simulated change set to '%s'", + output_file); + + Writer *writer = StringWriter(); + JsonWrite(writer, json, 0); + WriterWrite(writer, "\n"); + JsonDestroy(json); + char *escaped = StringWriterClose(writer); + char *document = RestoreUtf8InJson(escaped); + free(escaped); + + /* Write the document to a temporary file and rename() it to + * #output_file only once it is complete, so that a failure in the + * middle can never leave a truncated document behind or destroy a + * pre-existing #output_file. rename() also replaces a symbolic link at + * #output_file instead of writing through it. */ + char *tmp_file; + xasprintf(&tmp_file, "%s.new", output_file); + unlink(tmp_file); /* leftover from a previous run (if any) */ + int fd = safe_open_create_perms(tmp_file, O_WRONLY | O_CREAT | O_EXCL, + CF_PERMS_DEFAULT); + if (fd == -1) + { + Log(LOG_LEVEL_ERR, + "Failed to open '%s' for writing the simulated change set (open: %s)", + tmp_file, GetErrorStr()); + free(tmp_file); + free(document); + return false; + } + + const size_t document_len = strlen(document); + success = (FullWrite(fd, document, document_len) == (ssize_t) document_len); + free(document); + if (close(fd) == -1) + { + success = false; + } + if (!success) + { + Log(LOG_LEVEL_ERR, + "Failed to write the simulated change set to '%s' (write: %s)", + tmp_file, GetErrorStr()); + unlink(tmp_file); + free(tmp_file); + return false; + } + +#ifdef __MINGW32__ + /* rename() cannot replace an existing file on Windows. The document is + * safely written at this point, so the worst case is being left with + * just the temporary file (and a failure below). */ + unlink(output_file); +#endif + if (rename(tmp_file, output_file) == -1) + { + Log(LOG_LEVEL_ERR, + "Failed to move the simulated change set to '%s' (rename: %s)", + output_file, GetErrorStr()); + unlink(tmp_file); + free(tmp_file); + return false; + } + free(tmp_file); + + return true; +} diff --git a/cf-agent/simulate_mode.h b/cf-agent/simulate_mode.h index 4e85581301..9f6319297f 100644 --- a/cf-agent/simulate_mode.h +++ b/cf-agent/simulate_mode.h @@ -50,4 +50,6 @@ bool DiffChangedFiles(StringSet **audited_files); bool DiffPkgOperations(); bool ManifestPkgOperations(); +bool WriteChangesJson(const char *output_file); + #endif /* _SIMULATE_H_ */ diff --git a/tests/acceptance/29_simulate_mode/simulate_json.cf b/tests/acceptance/29_simulate_mode/simulate_json.cf new file mode 100644 index 0000000000..50c962b8c7 --- /dev/null +++ b/tests/acceptance/29_simulate_mode/simulate_json.cf @@ -0,0 +1,65 @@ +body common control +{ + inputs => { + "../default.sub.cf", + "./prepare_files_for_simulate_tests.cf.sub", + }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent init +{ + methods: + "prepare_files_for_simulate_tests"; +} + +bundle agent test +{ + meta: + "test_soft_fail" + string => "(solaris|aix|hpux|windows)", + # ENT-6540 exotics fail to delete chroot + # ENT-10254 tests fail on Windows due to CRLF + meta => { "ENT-6540,ENT-10254" }; + + "description" -> { "CFE-4716" } + string => "Test that --simulate-json writes a proper JSON change set and only makes changes in chroot"; + + commands: + # add --verbose here and look at the .prose.temp log for debugging sub policy runs + "$(sys.cf_agent) -Kf $(this.promise_dirname)$(const.dirsep)promises.cf.sub --simulate=manifest --simulate-json=$(this.promise_filename).json.temp > $(this.promise_filename).prose.temp 2>&1" + contain => in_shell, + comment => "Run sub policy in manifest mode and write the JSON change set to $(this.promise_filename).json.temp."; +} + +bundle agent normalize_json_results(original, normalized) +{ + commands: + "$(G.sed)" + args => " \ +-e 's,$(sys.workdir),WORKDIR,g' \ +-e '/\"sha256\": /d' \ +-e 's/\"size\": [0-9]*/\"size\": SIZE/' \ +-e 's/\"uid\": [0-9]*/\"uid\": UID/' \ +-e 's/\"gid\": [0-9]*/\"gid\": GID/' \ +$(original) > $(normalized)", + contain => in_shell, + comment => "Normalize workdir paths, sizes and owners so that we can compare to an expected output. The sha256 field is dropped entirely because it is omitted when the digest cannot be computed (e.g. the mode-0000 file when the test runs unprivileged), so its presence is privilege-dependent; the unit tests verify the digests."; +} + +bundle agent check +{ + methods: + "normalize_json_results" + usebundle => normalize_json_results( + "$(this.promise_filename).json.temp", "$(this.promise_filename).actual" + ); + + "check" + usebundle => dcs_check_diff( + "$(this.promise_filename).actual", + "$(this.promise_filename).expected", + "$(this.promise_filename)" + ); +} diff --git a/tests/acceptance/29_simulate_mode/simulate_json.cf.expected b/tests/acceptance/29_simulate_mode/simulate_json.cf.expected new file mode 100644 index 0000000000..3a6f24901f --- /dev/null +++ b/tests/acceptance/29_simulate_mode/simulate_json.cf.expected @@ -0,0 +1,158 @@ +{ + "files": [ + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/source-file", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/create-true", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/insert-lines", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/SUBDIR", + "permissions": "0700", + "type": "directory", + "uid": UID + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/copy-from", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "deleted", + "path": "WORKDIR/tmp/delete-me" + }, + { + "change": "deleted", + "path": "WORKDIR/tmp/sub-dir/./sub-file" + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/sub-dir/.", + "permissions": "0700", + "type": "directory", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/set-colon-field", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/delete-lines-matching", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/regex-replace", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/edit-template-string", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/build-xpath", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/xml-insert-tree", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "modified", + "gid": GID, + "path": "WORKDIR/tmp/perms", + "permissions": "0000", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "deleted", + "path": "WORKDIR/tmp/transformer" + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/hardlink", + "permissions": "0600", + "size": SIZE, + "type": "regular file", + "uid": UID + }, + { + "change": "created", + "gid": GID, + "path": "WORKDIR/tmp/link", + "permissions": "0700", + "target": "WORKDIR/tmp/already-created", + "type": "symbolic link", + "uid": UID + } + ], + "format_version": 1, + "packages": [], + "renames": [ + { + "new_name": "WORKDIR/tmp/rename-newname", + "old_name": "WORKDIR/tmp/rename-me" + } + ], + "simulate_mode": "manifest" +} diff --git a/tests/unit/Makefile.am b/tests/unit/Makefile.am index 3864b67876..a1003bb151 100644 --- a/tests/unit/Makefile.am +++ b/tests/unit/Makefile.am @@ -138,6 +138,7 @@ check_PROGRAMS = \ variable_test \ verify_databases_test \ files_properties_test \ + simulate_mode_test \ protocol_test \ mon_cpu_test \ mon_load_test \ @@ -399,6 +400,8 @@ verify_databases_test_LDADD = ../../cf-agent/libcf-agent.la libtest.la files_properties_test_LDADD = ../../cf-agent/libcf-agent.la libtest.la +simulate_mode_test_LDADD = ../../cf-agent/libcf-agent.la libtest.la + iteration_test_SOURCES = iteration_test.c cf_upgrade_test_SOURCES = cf_upgrade_test.c \ diff --git a/tests/unit/simulate_mode_test.c b/tests/unit/simulate_mode_test.c new file mode 100644 index 0000000000..ffcf64cfc8 --- /dev/null +++ b/tests/unit/simulate_mode_test.c @@ -0,0 +1,578 @@ +/* + Copyright 2024 Northern.tech AS + + This file is part of CFEngine 3 - written and maintained by Northern.tech AS. + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the + Free Software Foundation; version 3. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + + To the extent this program is licensed as part of the Enterprise + versions of CFEngine, the applicable Commercial Open Source License + (COSL) may apply to this file if you as a licensee so wish it. See + included file COSL.txt. +*/ + +#include + +#include /* SetChangesChroot(), ToChangesChroot() */ +#include /* CHROOT_CHANGES_LIST_FILE */ +#include /* JsonParseFile() */ +#include /* FileWriter() */ +#include /* WriteLenPrefixedString() */ +#include /* MakeParentDirectory() */ +#include /* DeleteDirectoryTree() */ +#include /* xsnprintf() */ + +#include + +static char CHROOT_DIR[] = "/tmp/simulate_mode_test_chroot.XXXXXX"; +static char ORIG_DIR[] = "/tmp/simulate_mode_test_orig.XXXXXX"; +static char OUTPUT_FILE[PATH_MAX]; + +static void test_setup(void) +{ + assert_true(mkdtemp(CHROOT_DIR) != NULL); + assert_true(mkdtemp(ORIG_DIR) != NULL); + xsnprintf(OUTPUT_FILE, sizeof(OUTPUT_FILE), "%s/change_set.json", + ORIG_DIR); + + SetChangesChroot(CHROOT_DIR); + EVAL_MODE = EVAL_MODE_SIMULATE_MANIFEST; +} + +static void test_teardown(void) +{ + assert_true(DeleteDirectoryTree(CHROOT_DIR)); + rmdir(CHROOT_DIR); + assert_true(DeleteDirectoryTree(ORIG_DIR)); + rmdir(ORIG_DIR); +} + +/* All tests share the one changes chroot (SetChangesChroot() can only be + * called once), so each test starts by removing the record files and the + * output written by the previous one. */ +static void reset_records(void) +{ + unlink(ToChangesChroot(CHROOT_CHANGES_LIST_FILE)); + unlink(ToChangesChroot(CHROOT_RENAMES_LIST_FILE)); + unlink(ToChangesChroot(CHROOT_PKGS_OPS_FILE)); + unlink(OUTPUT_FILE); +} + +static void write_changed_files(const char *const paths[], size_t n) +{ + FILE *file = fopen(ToChangesChroot(CHROOT_CHANGES_LIST_FILE), "w"); + assert_true(file != NULL); + Writer *writer = FileWriter(file); + for (size_t i = 0; i < n; i++) + { + assert_true(WriteLenPrefixedString(writer, paths[i])); + } + WriterClose(writer); +} + +/* #names contains consecutive pairs of the original and the new name, just + * like the records written by RecordFileRenamedInChroot(). */ +static void write_renamed_files(const char *const names[], size_t n) +{ + FILE *file = fopen(ToChangesChroot(CHROOT_RENAMES_LIST_FILE), "w"); + assert_true(file != NULL); + Writer *writer = FileWriter(file); + for (size_t i = 0; i < n; i++) + { + assert_true(WriteLenPrefixedString(writer, names[i])); + } + WriterClose(writer); +} + +/* #csv contains "op,name,version,architecture" records terminated by "\r\n", + * just like the records written by RecordPkgOperationInChroot(). */ +static void write_pkgs_ops(const char *csv) +{ + FILE *file = fopen(ToChangesChroot(CHROOT_PKGS_OPS_FILE), "w"); + assert_true(file != NULL); + assert_true(fputs(csv, file) >= 0); + fclose(file); +} + +static void create_chroot_file(const char *path, const char *content, + mode_t mode) +{ + char chrooted[PATH_MAX]; + strlcpy(chrooted, ToChangesChroot(path), sizeof(chrooted)); + + /* MakeParentDirectory() maps the path into the changes chroot on its own + * (EVAL_MODE is one of the simulate modes here). */ + assert_true(MakeParentDirectory(path, true, NULL)); + + FILE *file = fopen(chrooted, "w"); + assert_true(file != NULL); + assert_true(fputs(content, file) >= 0); + fclose(file); + + assert_int_equal(chmod(chrooted, mode), 0); +} + +static JsonElement *WriteAndParseChanges(void) +{ + assert_true(WriteChangesJson(OUTPUT_FILE)); + + JsonElement *json = NULL; + assert_int_equal(JsonParseFile(OUTPUT_FILE, 1024 * 1024, &json), + JSON_PARSE_OK); + assert_true(json != NULL); + return json; +} + +/* Reads the raw bytes of the output file. The encoding tests below need + * them because JsonParseFile() decodes a "\u00XX" escape into the raw byte + * 0xXX, so it cannot tell a (wrongly) escaped byte from a raw one. */ +static void read_output_file_raw(char *buf, size_t buf_size) +{ + FILE *file = fopen(OUTPUT_FILE, "r"); + assert_true(file != NULL); + size_t n_read = fread(buf, 1, buf_size - 1, file); + fclose(file); + assert_true(n_read > 0); + buf[n_read] = '\0'; +} + +static void test_empty_change_set(void) +{ + reset_records(); + + JsonElement *json = WriteAndParseChanges(); + + assert_int_equal( + JsonPrimitiveGetAsInteger(JsonObjectGet(json, "format_version")), 1); + assert_string_equal(JsonObjectGetAsString(json, "simulate_mode"), + "manifest"); + assert_int_equal(JsonLength(JsonObjectGetAsArray(json, "files")), 0); + assert_int_equal(JsonLength(JsonObjectGetAsArray(json, "renames")), 0); + assert_int_equal(JsonLength(JsonObjectGetAsArray(json, "packages")), 0); + + JsonDestroy(json); +} + +static void test_simulate_mode_string(void) +{ + reset_records(); + + EVAL_MODE = EVAL_MODE_SIMULATE_DIFF; + JsonElement *json = WriteAndParseChanges(); + assert_string_equal(JsonObjectGetAsString(json, "simulate_mode"), "diff"); + JsonDestroy(json); + EVAL_MODE = EVAL_MODE_SIMULATE_MANIFEST; +} + +static void test_created_file(void) +{ + reset_records(); + + const char *const path = "/simulate-test/created-file"; + create_chroot_file(path, "Hello, CFEngine!\n", 0640); + write_changed_files(&path, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + + struct stat st; + assert_int_equal(lstat(ToChangesChroot(path), &st), 0); + + JsonElement *file_info = JsonArrayGetAsObject(files, 0); + assert_string_equal(JsonObjectGetAsString(file_info, "path"), path); + assert_string_equal(JsonObjectGetAsString(file_info, "change"), "created"); + assert_string_equal(JsonObjectGetAsString(file_info, "type"), + "regular file"); + assert_string_equal(JsonObjectGetAsString(file_info, "permissions"), + "0640"); + assert_int_equal( + JsonPrimitiveGetAsInteger(JsonObjectGet(file_info, "uid")), + (long) st.st_uid); + assert_int_equal( + JsonPrimitiveGetAsInteger(JsonObjectGet(file_info, "gid")), + (long) st.st_gid); + assert_int_equal( + JsonPrimitiveGetAsInteger(JsonObjectGet(file_info, "size")), + (long) strlen("Hello, CFEngine!\n")); + assert_string_equal( + JsonObjectGetAsString(file_info, "sha256"), + "9be7023e1f91bae9d1f734b49c579cc2091c71924ae7494c9a5a3a8006527615"); + + JsonDestroy(json); +} + +static void test_deleted_file(void) +{ + reset_records(); + + /* Recorded as changed, but neither the file nor its in-chroot copy + * exists. */ + const char *const path = "/simulate-test/deleted-file"; + write_changed_files(&path, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + + JsonElement *file_info = JsonArrayGetAsObject(files, 0); + assert_string_equal(JsonObjectGetAsString(file_info, "path"), path); + assert_string_equal(JsonObjectGetAsString(file_info, "change"), "deleted"); + + /* Nothing to describe when the file would no longer exist. */ + assert_true(JsonObjectGet(file_info, "type") == NULL); + assert_true(JsonObjectGet(file_info, "size") == NULL); + assert_true(JsonObjectGet(file_info, "sha256") == NULL); + + JsonDestroy(json); +} + +static void test_modified_file(void) +{ + reset_records(); + + char path[PATH_MAX]; + xsnprintf(path, sizeof(path), "%s/modified-file", ORIG_DIR); + + FILE *file = fopen(path, "w"); + assert_true(file != NULL); + assert_true(fputs("contents before the run\n", file) >= 0); + fclose(file); + + create_chroot_file(path, "new contents after the run\n", 0644); + const char *paths[] = { path }; + write_changed_files(paths, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + + JsonElement *file_info = JsonArrayGetAsObject(files, 0); + assert_string_equal(JsonObjectGetAsString(file_info, "path"), path); + assert_string_equal(JsonObjectGetAsString(file_info, "change"), + "modified"); + + /* The digest must be of the would-be contents, not the current ones. */ + assert_string_equal( + JsonObjectGetAsString(file_info, "sha256"), + "6ba024f3c03f13f9a8c1bb444829640c68553009facb418bb4552dd7ddebe427"); + + JsonDestroy(json); +} + +static void test_duplicate_records(void) +{ + reset_records(); + + /* Files changed multiple times during a run are recorded multiple times, + * but must only be reported once. */ + const char *const path = "/simulate-test/created-file"; + create_chroot_file(path, "Hello, CFEngine!\n", 0640); + const char *const paths[] = { path, path, path }; + write_changed_files(paths, 3); + + JsonElement *json = WriteAndParseChanges(); + assert_int_equal(JsonLength(JsonObjectGetAsArray(json, "files")), 1); + + JsonDestroy(json); +} + +static void test_special_characters_in_path(void) +{ + reset_records(); + + const char *const path = "/simulate-test/w\xC3\xA9" "ird \"file\" \\ name"; + create_chroot_file(path, "", 0600); + write_changed_files(&path, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + + /* The path must survive JSON escaping and parsing untouched. */ + JsonElement *file_info = JsonArrayGetAsObject(files, 0); + assert_string_equal(JsonObjectGetAsString(file_info, "path"), path); + assert_string_equal(JsonObjectGetAsString(file_info, "change"), "created"); + + JsonDestroy(json); + + /* The UTF-8 character must appear in the document as its raw bytes -- + * as per-byte "\u00XX" escapes, a conformant JSON parser would decode + * it as two wrong characters (the parser above reverses such escapes, + * so it cannot detect them). */ + char raw[4096]; + read_output_file_raw(raw, sizeof(raw)); + assert_true(strstr(raw, "w\xC3\xA9" "ird") != NULL); + assert_true(strstr(raw, "\\u00c3") == NULL); +} + +static void test_invalid_utf8_in_path(void) +{ + reset_records(); + + /* File names are not guaranteed to be valid UTF-8. A byte that is not + * part of a valid UTF-8 sequence has to stay escaped as "\u00XX" -- + * raw, it would make the whole document invalid UTF-8. No chroot file + * is created here (the file system may refuse such a name), so the + * file is reported as deleted, which is enough to get the path into + * the document. */ + const char *const path = "/simulate-test/latin1-\xE9-name"; + write_changed_files(&path, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + assert_string_equal( + JsonObjectGetAsString(JsonArrayGetAsObject(files, 0), "path"), path); + JsonDestroy(json); + + char raw[4096]; + read_output_file_raw(raw, sizeof(raw)); + assert_true(strstr(raw, "\\u00e9") != NULL); +} + +static void test_write_failure(void) +{ + reset_records(); + + /* A failed write must be reported to the caller (the agent then exits + * non-zero) -- a consumer must not see a successful run without the + * document it asked for. */ + char bad_output[PATH_MAX]; + xsnprintf(bad_output, sizeof(bad_output), "%s/no-such-dir/change_set.json", + ORIG_DIR); + assert_false(WriteChangesJson(bad_output)); +} + +static void test_overwrite_output_file(void) +{ + reset_records(); + + /* An existing output file is replaced by the new document. */ + FILE *file = fopen(OUTPUT_FILE, "w"); + assert_true(file != NULL); + assert_true(fputs("not a JSON document", file) >= 0); + fclose(file); + + JsonElement *json = WriteAndParseChanges(); + JsonDestroy(json); +} + +#ifndef __MINGW32__ +static void test_output_symlink_not_followed(void) +{ + reset_records(); + + /* If the output path is a symbolic link, the document must replace the + * link instead of being written through it. */ + char link_target[PATH_MAX]; + xsnprintf(link_target, sizeof(link_target), "%s/link-target", ORIG_DIR); + FILE *file = fopen(link_target, "w"); + assert_true(file != NULL); + assert_true(fputs("do not overwrite\n", file) >= 0); + fclose(file); + assert_int_equal(symlink(link_target, OUTPUT_FILE), 0); + + JsonElement *json = WriteAndParseChanges(); + JsonDestroy(json); + + /* The output file is a regular file now... */ + struct stat st; + assert_int_equal(lstat(OUTPUT_FILE, &st), 0); + assert_true(S_ISREG(st.st_mode)); + + /* ...and the former link target is untouched. */ + char buf[64] = {0}; + file = fopen(link_target, "r"); + assert_true(file != NULL); + assert_true(fread(buf, 1, sizeof(buf) - 1, file) > 0); + fclose(file); + assert_string_equal(buf, "do not overwrite\n"); + unlink(link_target); +} +#endif /* !__MINGW32__ */ + +#ifndef __MINGW32__ +static void test_created_symlink(void) +{ + reset_records(); + + const char *const target = "/simulate-test/link-target"; + const char *const path = "/simulate-test/created-link"; + create_chroot_file(target, "target contents\n", 0644); + + /* Links created in the chroot point to the chrooted target paths (they + * are created as if the chroot was the root of the file system). */ + char chrooted_target[PATH_MAX]; + strlcpy(chrooted_target, ToChangesChroot(target), sizeof(chrooted_target)); + char chrooted_link[PATH_MAX]; + strlcpy(chrooted_link, ToChangesChroot(path), sizeof(chrooted_link)); + assert_int_equal(symlink(chrooted_target, chrooted_link), 0); + + write_changed_files(&path, 1); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *files = JsonObjectGetAsArray(json, "files"); + assert_int_equal(JsonLength(files), 1); + + JsonElement *file_info = JsonArrayGetAsObject(files, 0); + assert_string_equal(JsonObjectGetAsString(file_info, "change"), "created"); + assert_string_equal(JsonObjectGetAsString(file_info, "type"), + "symbolic link"); + + /* The target must be reported as a path outside of the chroot. */ + assert_string_equal(JsonObjectGetAsString(file_info, "target"), target); + + JsonDestroy(json); +} +#endif /* !__MINGW32__ */ + +static void test_renamed_files(void) +{ + reset_records(); + + const char *const names[] = { + "/simulate-test/old-name", "/simulate-test/new-name", + "/simulate-test/old-name-2", "/simulate-test/new-name-2", + }; + write_renamed_files(names, 4); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *renames = JsonObjectGetAsArray(json, "renames"); + assert_int_equal(JsonLength(renames), 2); + + JsonElement *rename = JsonArrayGetAsObject(renames, 0); + assert_string_equal(JsonObjectGetAsString(rename, "old_name"), names[0]); + assert_string_equal(JsonObjectGetAsString(rename, "new_name"), names[1]); + + rename = JsonArrayGetAsObject(renames, 1); + assert_string_equal(JsonObjectGetAsString(rename, "old_name"), names[2]); + assert_string_equal(JsonObjectGetAsString(rename, "new_name"), names[3]); + + JsonDestroy(json); +} + +static void check_single_pkg_operation(const char *csv, const char *operation, + const char *name, const char *version, + const char *architecture) +{ + reset_records(); + write_pkgs_ops(csv); + + JsonElement *json = WriteAndParseChanges(); + JsonElement *packages = JsonObjectGetAsArray(json, "packages"); + assert_int_equal(JsonLength(packages), 1); + + JsonElement *op_info = JsonArrayGetAsObject(packages, 0); + assert_string_equal(JsonObjectGetAsString(op_info, "operation"), + operation); + assert_string_equal(JsonObjectGetAsString(op_info, "name"), name); + if (version != NULL) + { + assert_string_equal(JsonObjectGetAsString(op_info, "version"), + version); + } + else + { + assert_true(JsonObjectGet(op_info, "version") == NULL); + } + if (architecture != NULL) + { + assert_string_equal(JsonObjectGetAsString(op_info, "architecture"), + architecture); + } + else + { + assert_true(JsonObjectGet(op_info, "architecture") == NULL); + } + + JsonDestroy(json); +} + +static void check_empty_pkg_operations(const char *csv) +{ + reset_records(); + write_pkgs_ops(csv); + + JsonElement *json = WriteAndParseChanges(); + assert_int_equal(JsonLength(JsonObjectGetAsArray(json, "packages")), 0); + JsonDestroy(json); +} + +static void test_pkg_operations(void) +{ + check_single_pkg_operation("i,pkg,1.0,x86_64\r\n", + "install", "pkg", "1.0", "x86_64"); + check_single_pkg_operation("r,pkg,,\r\n", + "remove", "pkg", NULL, NULL); + + /* A newer version of the same package wins. */ + check_single_pkg_operation("i,pkg,1.0,\r\ni,pkg,2.0,\r\n", + "install", "pkg", "2.0", NULL); + + /* An 'absent' operation with a version that doesn't match the version + * that would be installed would fail to remove the package. */ + check_single_pkg_operation("i,pkg,2.0,\r\na,pkg,1.0,\r\n", + "install", "pkg", "2.0", NULL); +} + +static void test_pkg_operations_cancel(void) +{ + /* A 'present' operation after a 'remove' operation means the package + * would be installed back, so there is no net change. */ + check_empty_pkg_operations("r,pkg,,\r\np,pkg,,\r\n"); + + /* An 'absent' operation with a matching (or no) version after an + * 'install' operation cancels the installation. */ + check_empty_pkg_operations("i,pkg,2.0,\r\na,pkg,2.0,\r\n"); + check_empty_pkg_operations("i,pkg,2.0,\r\na,pkg,,\r\n"); + + /* An 'install' operation after a 'remove' operation cancels the + * removal -- the net result is just the installation. */ + check_single_pkg_operation("r,pkg,,\r\ni,pkg,1.0,\r\n", + "install", "pkg", "1.0", NULL); +} + +int main() +{ + const UnitTest tests[] = + { + unit_test(test_setup), + unit_test(test_empty_change_set), + unit_test(test_simulate_mode_string), + unit_test(test_created_file), + unit_test(test_deleted_file), + unit_test(test_modified_file), + unit_test(test_duplicate_records), + unit_test(test_special_characters_in_path), + unit_test(test_invalid_utf8_in_path), +#ifndef __MINGW32__ + unit_test(test_created_symlink), +#endif + unit_test(test_renamed_files), + unit_test(test_pkg_operations), + unit_test(test_pkg_operations_cancel), + unit_test(test_write_failure), + unit_test(test_overwrite_output_file), +#ifndef __MINGW32__ + unit_test(test_output_symlink_not_followed), +#endif + unit_test(test_teardown), + }; + + PRINT_TEST_BANNER(); + int ret = run_tests(tests); + + return ret; +}