Merge feat/p2-itemize-output: structured change output (-i, --list-only, --out-format, --log-file-format)

Client-only per-file event/format model (change_list.c). Reviewed
(c-review APPROVE WITH NITS, all fixed); PR #263.
This commit is contained in:
2026-09-06 12:52:46 +02:00
15 changed files with 879 additions and 12 deletions
+310
View File
@@ -0,0 +1,310 @@
#include "change_list.h"
#include "utils.h"
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
/* Itemize code emitted for a transferred regular file.
*
* Layout (rsync-compatible 11-char item): `>f` marks a regular file that was
* transferred to the remote host; the trailing nine markers are, in order,
* c(hecksum) s(ize) t(ime) p(erms) o(wner) g(roup) u(ser/acl) a(ttrs) x(attrs).
* Every marker is `+` (FastSync does not compare each attribute on the
* receiving side, so a sent file is reported as fully updated). Files that
* are already up to date print no line at all, matching rsync's single -i
* which only itemizes changes.
*
* Because the scanner only yields regular-file transfer candidates, `>d`
* (directory) lines are never produced; directories are not transferred as
* items by FastSync. */
#define ITEMIZE_SENT_FILE ">f+++++++++"
typedef struct {
char* data;
size_t length;
size_t capacity;
} StrBuf;
static void strbuf_free(StrBuf* buf) {
if (buf == NULL)
return;
free(buf->data);
buf->data = NULL;
buf->length = 0;
buf->capacity = 0;
}
static bool strbuf_reserve(StrBuf* buf, size_t extra) {
if (buf->length > SIZE_MAX - extra - 1)
return false;
size_t need = buf->length + extra + 1;
if (need <= buf->capacity)
return true;
size_t capacity = buf->capacity > 0 ? buf->capacity : 32;
while (capacity < need) {
if (capacity > SIZE_MAX / 2) {
capacity = need;
break;
}
capacity *= 2;
}
char* grown = realloc(buf->data, capacity);
if (!grown)
return false;
buf->data = grown;
buf->capacity = capacity;
return true;
}
static bool strbuf_append_char(StrBuf* buf, char c) {
if (!strbuf_reserve(buf, 1))
return false;
buf->data[buf->length++] = c;
buf->data[buf->length] = '\0';
return true;
}
static bool strbuf_append(StrBuf* buf, const char* text) {
if (text == NULL)
return true;
size_t length = strlen(text);
if (!strbuf_reserve(buf, length))
return false;
memcpy(buf->data + buf->length, text, length);
buf->length += length;
buf->data[buf->length] = '\0';
return true;
}
static bool strbuf_append_ull(StrBuf* buf, unsigned long long value) {
char digits[32];
int written = snprintf(digits, sizeof(digits), "%llu", value);
if (written < 0 || (size_t)written >= sizeof(digits))
return false;
return strbuf_append(buf, digits);
}
static bool strbuf_append_longlong(StrBuf* buf, long long value) {
char digits[32];
int written = snprintf(digits, sizeof(digits), "%lld", value);
if (written < 0 || (size_t)written >= sizeof(digits))
return false;
return strbuf_append(buf, digits);
}
bool change_list_enabled(const Config* config) {
return config != NULL && (config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_format != NULL));
}
char* change_render_itemize(const ChangeEvent* event) {
if (event == NULL || event->decision != CHANGE_SENT)
return str_dup("");
const char* code = event->is_directory ? ">d+++++++++" : ITEMIZE_SENT_FILE;
StrBuf line = {0};
bool ok = strbuf_append(&line, code) && strbuf_append(&line, " ") &&
strbuf_append(&line, event->path != NULL ? event->path : "");
if (!ok) {
strbuf_free(&line);
return NULL;
}
return line.data;
}
static const char* leaf_name(const char* path) {
if (path == NULL)
return "";
const char* slash = strrchr(path, '/');
return slash != NULL && slash[1] != '\0' ? slash + 1 : path;
}
char* change_render_format(const char* format, const ChangeEvent* event) {
if (format == NULL)
return NULL;
StrBuf line = {0};
bool ok = true;
for (const char* p = format; *p != '\0' && ok;) {
if (*p != '%') {
ok = strbuf_append_char(&line, *p);
p++;
continue;
}
char token = p[1];
if (token == '\0') {
ok = strbuf_append_char(&line, '%');
break;
}
switch (token) {
case '%':
ok = strbuf_append_char(&line, '%');
break;
case 'f':
ok = strbuf_append(&line, event->path != NULL ? event->path : "");
break;
case 'n':
ok = strbuf_append(&line, leaf_name(event->path));
break;
case 'l':
ok = strbuf_append_ull(&line, event->size);
break;
case 'b':
ok = strbuf_append_ull(&line, event->bytes_sent);
break;
case 'M':
ok = strbuf_append_longlong(&line, (long long)event->mtime_sec);
break;
default:
/* Unknown escape sequences are preserved verbatim. */
ok = strbuf_append_char(&line, '%') && strbuf_append_char(&line, token);
break;
}
p += 2;
}
if (!ok) {
strbuf_free(&line);
return NULL;
}
if (line.data == NULL) {
line.data = str_dup("");
if (!line.data)
return NULL;
}
return line.data;
}
/* Format a mode as an `ls -l` permission string, e.g. `-rw-r--r--`. */
static void mode_to_ls_string(mode_t mode, char out[11]) {
out[0] = S_ISDIR(mode) ? 'd'
: S_ISLNK(mode) ? 'l'
: S_ISCHR(mode) ? 'c'
: S_ISBLK(mode) ? 'b'
: S_ISFIFO(mode) ? 'p'
: S_ISSOCK(mode) ? 's'
: '-';
mode_t bits = mode & 07777;
out[1] = (bits & S_IRUSR) ? 'r' : '-';
out[2] = (bits & S_IWUSR) ? 'w' : '-';
out[3] = (bits & S_IXUSR) ? (bits & S_ISUID ? 's' : 'x') : (bits & S_ISUID ? 'S' : '-');
out[4] = (bits & S_IRGRP) ? 'r' : '-';
out[5] = (bits & S_IWGRP) ? 'w' : '-';
out[6] = (bits & S_IXGRP) ? (bits & S_ISGID ? 's' : 'x') : (bits & S_ISGID ? 'S' : '-');
out[7] = (bits & S_IROTH) ? 'r' : '-';
out[8] = (bits & S_IWOTH) ? 'w' : '-';
out[9] = (bits & S_IXOTH) ? (bits & S_ISVTX ? 't' : 'x') : (bits & S_ISVTX ? 'T' : '-');
out[10] = '\0';
}
char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime,
const char* path) {
char permission[11];
mode_to_ls_string(mode, permission);
char date[32];
struct tm broken_down;
if (localtime_r(&mtime, &broken_down) != NULL) {
if (strftime(date, sizeof(date), "%Y/%m/%d %H:%M:%S", &broken_down) == 0)
snprintf(date, sizeof(date), "?");
} else {
snprintf(date, sizeof(date), "?");
}
StrBuf line = {0};
char size_field[32];
int written = snprintf(size_field, sizeof(size_field), "%llu", size);
if (written < 0 || (size_t)written >= sizeof(size_field)) {
strbuf_free(&line);
return NULL;
}
bool ok = strbuf_append(&line, permission) && strbuf_append_char(&line, ' ') &&
strbuf_append(&line, size_field) && strbuf_append_char(&line, ' ') &&
strbuf_append(&line, date) && strbuf_append_char(&line, ' ') &&
strbuf_append(&line, path != NULL ? path : "");
if (!ok) {
strbuf_free(&line);
return NULL;
}
return line.data;
}
static void print_escaped_line(FILE* stream, const char* line, bool eight_bit_output) {
char* escaped = output_escape(line, eight_bit_output);
if (escaped != NULL) {
fprintf(stream, "%s\n", escaped);
free(escaped);
} else {
fprintf(stream, "%s\n", line);
}
fflush(stream);
}
void change_emit(const Config* config, const ChangeEvent* event) {
if (event == NULL || !change_list_enabled(config))
return;
if (event->decision == CHANGE_UP_TO_DATE)
return;
bool to_stdout = config->itemize_changes || config->out_format != NULL;
bool to_log = config->log_file != NULL && config->log_file_format != NULL;
if (to_stdout) {
char* line = config->out_format != NULL ? change_render_format(config->out_format, event)
: change_render_itemize(event);
if (line != NULL) {
print_escaped_line(stdout, line, config->eight_bit_output);
free(line);
}
}
if (to_log) {
char* line = change_render_format(config->log_file_format, event);
if (line != NULL) {
print_escaped_line(config->log_file, line, config->eight_bit_output);
free(line);
}
}
}
static bool format_uses_mtime(const char* format) {
if (format == NULL)
return false;
/* Mirror change_render_format's tokenizer: "%%" is a literal percent (so
* "%%M" does NOT expand %M) and unknown "%X" escapes consume both chars.
* This keeps the optional stat() fallback below in step with the renderer. */
for (const char* p = format; *p != '\0';) {
if (*p != '%') {
p++;
continue;
}
char token = p[1];
if (token == '\0')
break;
if (token == 'M')
return true;
p += 2;
}
return false;
}
void change_emit_file_sent(const Config* config, const File* file) {
if (file == NULL || !change_list_enabled(config))
return;
ChangeEvent event;
memset(&event, 0, sizeof(event));
event.path = file->path;
event.decision = CHANGE_SENT;
event.is_directory = false;
event.size = file->data != NULL ? file->data->size : 0;
/* FastSync has no wire-byte counter yet, so %b reports the source length
* that had to be delivered (always equal to %l); the actual bytes written
* to the socket (compressed/delta) are not measured. */
event.bytes_sent = event.size;
if (file->metadata != NULL) {
event.mtime_sec = file->metadata->mtime_sec;
} else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) {
/* Best-effort fallback for %M when no metadata was captured (no -M): the
* path is stat()ed just to fill the field, and any failure leaves 0. */
struct stat st;
if (file->path != NULL && stat(file->path, &st) == 0)
event.mtime_sec = st.st_mtime;
}
change_emit(config, &event);
}
+75
View File
@@ -0,0 +1,75 @@
#ifndef CHANGE_LIST_H
#define CHANGE_LIST_H
#include "config.h"
#include "file_types.h"
#include <stdbool.h>
#include <sys/stat.h>
#include <time.h>
/*
* Shared per-file change-event / output model (rsync --itemize-changes,
* --out-format, --log-file-format, and --list-only all render from here).
*
* FastSync is a push-style tool: the client sends files from the source tree
* to a server that writes them under the destination root. Events are
* emitted by whichever code path decides a file's fate (the single-threaded
* send loop and the `-m` sender thread both call the same per-file sender), so
* all change events are emitted by exactly one thread and itemize/out-format
* lines never interleave with each other. They may still interleave with
* legacy log messages (log.c) that share the same stdout/log-file stream.
*/
typedef enum {
CHANGE_SENT, /* file data (full or delta) was transmitted */
CHANGE_UP_TO_DATE, /* receiver already had an identical file; skipped */
} ChangeDecision;
typedef struct {
const char* path; /* full source path */
ChangeDecision decision;
bool is_directory;
unsigned long long size; /* source file length in bytes */
/* The number of bytes reported for a sent file. FastSync has no wire-byte
* counter, so this is always the source length (== size / %l); actual
* post-compression/delta bytes on the wire are not counted. */
unsigned long long bytes_sent;
time_t mtime_sec; /* 0 when unknown */
} ChangeEvent;
/* True when any output mode is active and per-file events matter. */
bool change_list_enabled(const Config* config);
/* Render the rsync-style itemize line for a transferred file:
* `>f+++++++++ <path>`
* The 11-char code is `>f` (regular file transferred to the remote host)
* followed by c/s/t/p/o/g/u/a/x markers that are all `+` (value will be set
* / differs) because FastSync does not separately compare checksums, size,
* mtime, perms, owner, group, uid, acl, or xattr on the receiving side, so a
* sent file is reported as fully updated. Up-to-date files print no line
* (rsync single `-i` only shows changes). Caller frees the result. */
char* change_render_itemize(const ChangeEvent* event);
/* Expand an --out-format/--log-file-format template. Tokens:
* %f full source path %b "bytes sent" == the source length (%l);
* %n leaf (base) name actual post-compression/delta wire bytes
* %l file length in bytes are not counted
* %M mtime in whole seconds %% a literal percent sign
* Unknown %X sequences are preserved verbatim. Caller frees the result. */
char* change_render_format(const char* format, const ChangeEvent* event);
/* Render one --list-only long-listing entry:
* `-rw-r--r-- 12 2026/09/06 10:00:00 <path>`
* (ls -l style columns; mtime in the local time zone). Caller frees it. */
char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime, const char* path);
/* Emit an event to every active destination:
* stdout: --itemize-changes line, or the --out-format expansion when set;
* log file: the --log-file-format expansion (requires --log-file).
* CHANGE_UP_TO_DATE events produce no output. */
void change_emit(const Config* config, const ChangeEvent* event);
/* Build and emit a CHANGE_SENT event for a file the client just sent. */
void change_emit_file_sent(const Config* config, const File* file);
#endif
+4
View File
@@ -390,6 +390,10 @@ static const OptionEntry OPTION_TABLE[] = {
{"--fsync", NULL, OPT_FLAG, offsetof(Config, use_fsync)},
{"--checksum", NULL, OPT_FLAG, offsetof(Config, checksum)},
{"--8-bit-output", "-8", OPT_FLAG, offsetof(Config, eight_bit_output)},
{"--itemize-changes", "-i", OPT_FLAG, offsetof(Config, itemize_changes)},
{"--list-only", NULL, OPT_FLAG, offsetof(Config, list_only)},
{"--out-format", NULL, OPT_STRING, offsetof(Config, out_format)},
{"--log-file-format", NULL, OPT_STRING, offsetof(Config, log_file_format)},
{"--existing", NULL, OPT_FLAG, offsetof(Config, existing)},
{"--ignore-existing", NULL, OPT_FLAG, offsetof(Config, ignore_existing)},
{"--chmod", NULL, OPT_STRING, offsetof(Config, chmod_spec)},
+117
View File
@@ -1,5 +1,6 @@
#include "client_send.h"
#include "array_list.h"
#include "change_list.h"
#include "chunk.h"
#include "compression.h"
#include "config.h"
@@ -292,6 +293,113 @@ static int send_dry_run_manifest(const Config* config) {
return 0;
}
typedef struct {
char* path;
mode_t mode;
unsigned long long size;
time_t mtime;
} ListEntry;
static void list_entries_destroy(ListEntry* entries, size_t count) {
if (entries == NULL)
return;
for (size_t i = 0; i < count; i++)
free(entries[i].path);
free(entries);
}
static int compare_list_entries(const void* left, const void* right) {
const ListEntry* a = (const ListEntry*)left;
const ListEntry* b = (const ListEntry*)right;
return strcmp(a->path, b->path);
}
/* --list-only: print an ls-style listing of the files that WOULD be
* transferred and exit without contacting the server or writing anything.
* Directory lines are not printed because the scanner only yields regular
* transfer candidates. Returns 0 on success, 1 on error. */
static int send_list_only(const Config* config) {
ScannerOptions options = scanner_options_from_config(config, 0);
options.use_metadata = true; /* capture mode + mtime for the listing */
DirectoryScanner* scanner =
directory_scanner_create_with_options(config->send_directory, &options);
if (!scanner)
return 1;
ListEntry* entries = NULL;
size_t count = 0;
size_t capacity = 0;
Chunk* chunk;
bool oom = false;
while ((chunk = directory_scanner_next(scanner)) != NULL) {
for (int i = 0; i < chunk->element_count; i++) {
File* f = chunk->items[i];
if (f == NULL)
continue;
if (count == capacity) {
size_t new_capacity = capacity > 0 ? capacity * 2 : 64;
if (new_capacity <= capacity) {
oom = true;
break;
}
ListEntry* grown = realloc(entries, new_capacity * sizeof(ListEntry));
if (!grown) {
oom = true;
break;
}
entries = grown;
capacity = new_capacity;
}
char* path = str_dup(f->path);
if (!path) {
oom = true;
break;
}
mode_t mode = 0;
time_t mtime = 0;
if (f->metadata != NULL) {
mode = f->metadata->mode;
mtime = f->metadata->mtime_sec;
} else {
struct stat st;
if (stat(f->path, &st) == 0) {
mode = st.st_mode;
mtime = st.st_mtime;
}
}
entries[count].path = path;
entries[count].mode = mode;
entries[count].mtime = mtime;
entries[count].size = f->data != NULL ? f->data->size : 0;
count++;
}
chunk_destroy(chunk);
if (oom)
break;
}
bool failed = oom || directory_scanner_failed(scanner);
directory_scanner_destroy(scanner);
if (failed) {
list_entries_destroy(entries, count);
if (oom)
log_message(LOG_LEVEL_ERROR, "memory allocation failed while listing");
return 1;
}
if (count > 1)
qsort(entries, count, sizeof(ListEntry), compare_list_entries);
for (size_t i = 0; i < count; i++) {
char* line = change_render_list_line(entries[i].mode, entries[i].size, entries[i].mtime,
entries[i].path);
if (line != NULL) {
char* escaped = output_escape(line, config->eight_bit_output);
printf("%s\n", escaped != NULL ? escaped : line);
free(escaped);
free(line);
}
}
list_entries_destroy(entries, count);
return 0;
}
/* Send the delete manifest (list of files) to the server. Returns 0 on success, -1 on failure. */
static int send_delete_manifest(int fd, ArrayList* manifest) {
if (!manifest)
@@ -533,6 +641,10 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
return -1;
}
data_destroy(data);
for (int i = 0; i < chunk->element_count; i++) {
if (chunk->items[i] != NULL)
change_emit_file_sent(config, chunk->items[i]);
}
return 0;
}
@@ -553,6 +665,7 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
source_file_destroy(source);
return -1;
}
change_emit_file_sent(config, f);
if (source && !array_list_add(remove_sources, source)) {
source_file_destroy(source);
return -1;
@@ -806,6 +919,8 @@ static int progress_thread_fn(void* arg) {
}
int send_files(Config* config) {
if (config->list_only)
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
@@ -937,6 +1052,8 @@ int send_files_multithreaded(Config** config_ptr) {
if (!config_ptr || !*config_ptr)
return 1;
Config* config = *config_ptr;
if (config->list_only)
return send_list_only(config);
if (config->dry_run)
return send_dry_run_manifest(config);
+4
View File
@@ -44,6 +44,10 @@ bool validate_config(const Config* config) {
log_message(LOG_LEVEL_ERROR, "--delta cannot be combined with -f (sendfile)");
return false;
}
if (config->log_file_format && !config->log_file) {
log_message(LOG_LEVEL_ERROR, "--log-file-format requires --log-file");
return false;
}
if (config->append || config->append_verify) {
fprintf(
stderr,
+4
View File
@@ -81,6 +81,10 @@ void print_usage(void) {
printf(" --backup-dir <dir> Directory for backups (requires --backup)\n");
printf(" --suffix <str> Backup suffix (default: ~)\n");
printf(" --stats Print transfer statistics at end\n");
printf(" -i, --itemize-changes Print an rsync-style per-file change line\n");
printf(" --out-format=FORMAT Output format for changed files (%%f %%n %%l %%b %%M %%%%)\n");
printf(" --list-only List source files instead of transferring\n");
printf(" --log-file-format=FORMAT Per-file log line format (needs --log-file)\n");
printf(" -h, --human-readable Print byte sizes in human-readable form\n");
printf(" --max-depth <n> Maximum directory depth (0=unlimited)\n");
printf(" -x, --one-file-system Do not cross filesystem boundaries\n");
+2
View File
@@ -74,6 +74,7 @@ static void config_set_defaults(Config* config) {
config->preserve_sparse = false;
config->itemize_changes = false;
config->out_format = NULL;
config->log_file_format = NULL;
config->info_level = 0;
config->debug_level = 0;
config->list_only = false;
@@ -224,6 +225,7 @@ void config_delete(Config* config) {
free(config->backup_dir);
free(config->server_host);
free(config->out_format);
free(config->log_file_format);
free(config->files_from);
free(config->rsh_command);
free(config->rsync_path);
+1
View File
@@ -79,6 +79,7 @@ typedef struct Config {
// Issue #122: Output/logging options
bool itemize_changes;
char* out_format;
char* log_file_format;
int info_level;
int debug_level;
bool list_only;