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
279 lines
7.4 KiB
C
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;
|
|
}
|