Files
CST-agent/src/undo.c
T
WHC 2a43ae6622 Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
Build
  * cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
    winpthreads and ncurses 6.6 are linked statically, so the release is
    cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
  * windres embeds a manifest with activeCodePage=UTF-8 and a version resource
  * ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
    wide-char declarations): scripts/patches/
  * scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
    UCRT64; PR #1's MINGW64 notes now point there
  * vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW

UTF-8
  * paths cross the system call boundary as UTF-16 (*W APIs); console code
    pages set to 65001 and restored on exit; files are read and written in
    binary mode
  * child output that is not valid UTF-8 is decoded from the console, OEM or
    ANSI system code page
  * read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
    endings and a UTF-8 BOM

OS layer and processes
  * os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
    subagent, plugins, skills, undo and fs_tools; realpath / path_under and
    symlink rejection work on Windows
  * every child goes through CreateProcessW with its own environment block
    inside a Job Object, so timeouts and Esc end the whole process tree

Sandbox (auto mode, "auto · lowil")
  * run_shell runs with a restricted Low integrity token; the workspace gets
    an inheritable Low label once, the sandbox TEMP is labeled Low, and the
    credentials file gets a no-read-up label
  * drive roots, the profile and system folders are never labeled
  * network commands still ask, since Low IL does not block the network;
    Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
  * /sandbox lists labeled folders, /sandbox reset-labels restores them

TUI
  * raster console fonts are switched to Consolas / NSimSun while running
  * the home logo falls back to text when the console font lacks U+2580
  * mouse drag copy goes to the Windows clipboard

Distribution
  * scripts/install_windows.ps1 installs a Gitea release into
    %LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
    cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
    zip with the token taken from the environment
  * scripts/run_windows.bat is gone; docs/README-windows.md is rewritten

Tests
  * new test_lowil, test_win_utf8 and test_os_dir; tests made portable
    (cst_test_util.h)
  * scripts/win_test.sh and scripts/winvm/ run the suite on English and
    Chinese Windows Server VMs as Administrator, a standard user and a
    Chinese user name: 29/29 on all six
2026-10-06 23:08:14 +08:00

279 lines
7.4 KiB
C

/* Per-turn file snapshots under <ws>/.agent/undo/<session>/<tag>/ so the
* file changes of the latest turn can be rolled back (/undo). */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cst_agent.h"
#include "cst_undo.h"
#include "cst_util.h"
#include "os_compat.h"
#define UNDO_FILE_MAX (8 * 1024 * 1024)
static int undo_root(const char *ws, const char *sid, char *out, size_t n) {
int k = snprintf(out, n, "%s/.agent/undo/%s", ws, sid && sid[0] ? sid : "_");
return k < 0 || (size_t)k >= n ? -1 : 0;
}
/* Workspace-relative, no "..", not absolute: undo may delete these paths. */
static int rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '/' || rel[0] == '\\' ||
(rel[0] && rel[1] == ':'))
return 0;
const char *p = rel;
while (*p) {
size_t n = strcspn(p, "/\\");
if (n == 2 && p[0] == '.' && p[1] == '.')
return 0;
p += n;
while (*p == '/' || *p == '\\')
p++;
}
return strchr(rel, '\n') == NULL && strchr(rel, '\t') == NULL;
}
static char *slurp(const char *p, size_t *len, size_t max) {
*len = 0;
FILE *fp = fopen(p, "rb");
if (!fp)
return NULL;
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
if (sz < 0 || (size_t)sz > max) {
fclose(fp);
return NULL;
}
rewind(fp);
char *b = malloc((size_t)sz + 1);
if (!b) {
fclose(fp);
return NULL;
}
*len = fread(b, 1, (size_t)sz, fp);
b[*len] = 0;
fclose(fp);
return b;
}
static int put(const char *p, const char *d, size_t n) {
FILE *fp = fopen(p, "wb");
if (!fp)
return -1;
int ok = n == 0 || fwrite(d, 1, n, fp) == n;
return fclose(fp) == 0 && ok ? 0 : -1;
}
static int manifest_has(const char *mf, const char *rel) {
FILE *fp = fopen(mf, "rb");
if (!fp)
return 0;
char line[1400];
int found = 0;
while (!found && fgets(line, sizeof(line), fp)) {
char *t2 = strchr(line, '\t');
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
if (!t3)
continue;
t3++;
size_t n = strlen(t3);
if (n && t3[n - 1] == '\n')
t3[--n] = 0;
found = strcmp(t3, rel) == 0;
}
fclose(fp);
return found;
}
int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
const char *rel) {
if (!ws || !tag || !rel_ok(rel))
return -1;
char full[OS_PATH_MAX];
if (cst_sandbox_resolve(ws, rel, full, sizeof(full)) != 0) {
/* parent missing: the file is new; record it as absent */
snprintf(full, sizeof(full), "%s", "");
}
char dir[OS_PATH_MAX], mf[OS_PATH_MAX];
if (undo_root(ws, sid, dir, sizeof(dir)) != 0)
return -1;
size_t dl = strlen(dir);
int k = snprintf(dir + dl, sizeof(dir) - dl, "/%s", tag);
if (k < 0 || (size_t)k >= sizeof(dir) - dl)
return -1;
if (os_mkdir_p(dir, 0755) != 0)
return -1;
k = snprintf(mf, sizeof(mf), "%s/MANIFEST", dir);
if (k < 0 || (size_t)k >= sizeof(mf))
return -1;
if (manifest_has(mf, rel))
return 0;
int idx = 0;
{
FILE *fp = fopen(mf, "rb");
if (fp) {
int ch;
while ((ch = fgetc(fp)) != EOF)
idx += ch == '\n';
fclose(fp);
}
}
const char *state = "absent";
struct stat st;
if (full[0] && stat(full, &st) == 0 && S_ISREG(st.st_mode)) {
size_t n = 0;
char *data = slurp(full, &n, UNDO_FILE_MAX);
if (!data) {
state = "toolarge";
} else {
char bak[OS_PATH_MAX + 16];
int bl = snprintf(bak, sizeof(bak), "%s/%d.bak", dir, idx);
state = bl > 0 && (size_t)bl < sizeof(bak) && put(bak, data, n) == 0
? "file"
: "toolarge";
free(data);
}
}
FILE *fp = fopen(mf, "ab");
if (!fp)
return -1;
fprintf(fp, "%d\t%s\t%s\n", idx, state, rel);
fclose(fp);
return 0;
}
static int latest_tag(const char *root, char *tag, size_t n) {
os_dir_t *d = os_dir_open(root);
if (!d)
return -1;
const os_dirent_t *de;
tag[0] = 0;
while ((de = os_dir_next(d)) != NULL) {
if (de->name[0] == '.')
continue;
if (strcmp(de->name, tag) > 0)
snprintf(tag, n, "%s", de->name);
}
os_dir_close(d);
return tag[0] ? 0 : -1;
}
static void rm_dir(const char *dir) {
os_dir_t *d = os_dir_open(dir);
if (d) {
const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL) {
if (strcmp(de->name, ".") == 0 || strcmp(de->name, "..") == 0)
continue;
char p[OS_PATH_MAX];
if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) < (int)sizeof(p))
unlink(p);
}
os_dir_close(d);
}
rmdir(dir);
}
int cst_undo_count(const char *ws, const char *sid) {
char root[OS_PATH_MAX];
if (undo_root(ws, sid, root, sizeof(root)) != 0)
return 0;
os_dir_t *d = os_dir_open(root);
if (!d)
return 0;
int n = 0;
const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL)
n += de->name[0] != '.';
os_dir_close(d);
return n;
}
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
size_t report_sz) {
report[0] = 0;
char root[OS_PATH_MAX], tag[256], dir[OS_PATH_MAX], mf[OS_PATH_MAX];
if (undo_root(ws, sid, root, sizeof(root)) != 0 ||
latest_tag(root, tag, sizeof(tag)) != 0) {
snprintf(report, report_sz, "nothing to undo");
return -1;
}
if (snprintf(dir, sizeof(dir), "%s/%s", root, tag) >= (int)sizeof(dir) ||
snprintf(mf, sizeof(mf), "%s/MANIFEST", dir) >= (int)sizeof(mf)) {
snprintf(report, report_sz, "undo path too long");
return -1;
}
FILE *fp = fopen(mf, "rb");
cst_buf_t rep = {0};
int restored = 0, failed = 0;
if (fp) {
char line[1400];
while (fgets(line, sizeof(line), fp)) {
size_t n = strlen(line);
if (n && line[n - 1] == '\n')
line[--n] = 0;
char *t2 = strchr(line, '\t');
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
if (!t3)
continue;
*t2++ = 0;
*t3++ = 0;
const char *rel = t3, *state = t2;
char full[OS_PATH_MAX];
os_stat_t lst;
if (!rel_ok(rel) || os_path_join(full, sizeof(full), ws, rel) != 0)
continue;
if (os_lstat(full, &lst) == 0 && lst.is_link) {
cst_buf_printf(&rep, "skipped %s (now a symlink)\n", rel);
failed++;
continue;
}
if (strcmp(state, "file") == 0) {
char bak[OS_PATH_MAX];
if (snprintf(bak, sizeof(bak), "%s/%s.bak", dir, line) >=
(int)sizeof(bak))
bak[0] = 0;
size_t len = 0;
char *data = slurp(bak, &len, UNDO_FILE_MAX);
char parent[OS_PATH_MAX];
snprintf(parent, sizeof(parent), "%s", full);
char *sl = strrchr(parent, '/');
if (sl) {
*sl = 0;
os_mkdir_p(parent, 0755);
}
if (data && put(full, data, len) == 0) {
cst_buf_printf(&rep, "restored %s\n", rel);
restored++;
} else {
cst_buf_printf(&rep, "FAILED to restore %s\n", rel);
failed++;
}
free(data);
} else if (strcmp(state, "absent") == 0) {
if (unlink(full) == 0) {
cst_buf_printf(&rep, "removed %s (did not exist before)\n", rel);
restored++;
}
} else {
cst_buf_printf(&rep, "skipped %s (too large to snapshot)\n", rel);
failed++;
}
}
fclose(fp);
}
rm_dir(dir);
if (!rep.buf)
cst_buf_puts(&rep, "nothing changed\n");
snprintf(report, report_sz, "%s", rep.buf);
cst_buf_free(&rep);
return failed && !restored ? -1 : 0;
}