Skip to content

Commit 69026da

Browse files
gpsheadmiss-islington
authored andcommitted
gh-123018: Keep the libedit history file header when truncating (GH-157165)
libedit's history_truncate_file() keeps the last N lines of the file, which drops the "_HiStOrY_V2_" header line that its own write_history() emits and that its read_history() requires. So on a libedit build, readline.write_history_file() or readline.append_history_file() after readline.set_history_length() produced a file that readline.read_history_file() rejected with EINVAL. Under the libedit emulation, truncate the file ourselves and keep the header, resolving the default "~/.history" the same way libedit does. Apple's libedit fork already preserves the header, so the workaround is not compiled on macOS. (cherry picked from commit 2638785) Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
1 parent cf312cf commit 69026da

3 files changed

Lines changed: 227 additions & 2 deletions

File tree

Lib/test/test_readline.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,44 @@ def test_write_read_limited_history(self):
168168
# Readline seems to report an additional history element.
169169
self.assertIn(readline.get_current_history_length(), (2, 3))
170170

171+
def test_write_read_zero_length_history(self):
172+
previous_length = readline.get_history_length()
173+
self.addCleanup(readline.set_history_length, previous_length)
174+
175+
readline.clear_history()
176+
readline.add_history("first line")
177+
readline.set_history_length(0)
178+
readline.write_history_file(TESTFN)
179+
self.addCleanup(os.remove, TESTFN)
180+
181+
readline.clear_history()
182+
# libedit cannot read an empty history file, only one that still
183+
# has its header line. How many items remain is not checked:
184+
# libedit's own history_truncate_file() ignores a length of 0.
185+
readline.read_history_file(TESTFN)
186+
187+
@unittest.skipUnless(hasattr(readline, "append_history_file"),
188+
"append_history not available")
189+
def test_append_limited_history(self):
190+
previous_length = readline.get_history_length()
191+
self.addCleanup(readline.set_history_length, previous_length)
192+
193+
readline.clear_history()
194+
readline.add_history("first line")
195+
readline.add_history("second line")
196+
readline.write_history_file(TESTFN)
197+
self.addCleanup(os.remove, TESTFN)
198+
199+
readline.add_history("third line")
200+
readline.set_history_length(2)
201+
readline.append_history_file(1, TESTFN)
202+
203+
readline.clear_history()
204+
readline.read_history_file(TESTFN)
205+
self.assertEqual(readline.get_history_item(1), "second line")
206+
self.assertEqual(readline.get_history_item(2), "third line")
207+
self.assertEqual(readline.get_history_item(3), None)
208+
171209

172210
class TestReadline(unittest.TestCase):
173211

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fix :func:`readline.write_history_file` and
2+
:func:`readline.append_history_file` producing a history file that
3+
:func:`readline.read_history_file` could not load when a limit was set with
4+
:func:`readline.set_history_length` and Python was built against
5+
``libedit``. This works around `NetBSD PR 60322
6+
<https://gnats.netbsd.org/60322>`_.

Modules/readline.c

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@
1010

1111
/* Standard definitions */
1212
#include "Python.h"
13+
#include "pycore_fileutils.h" // _Py_open_noraise()
1314
#include "pycore_pyatomic_ft_wrappers.h"
1415
#include "pycore_pylifecycle.h" // _Py_SetLocaleFromEnv()
1516

1617
#include <errno.h> // errno
18+
#include <pwd.h> // getpwuid_r()
1719
#include <signal.h> // SIGWINCH
1820
#include <stdlib.h> // free()
1921
#include <string.h> // strdup()
22+
#ifdef HAVE_FCNTL_H
23+
# include <fcntl.h> // O_RDWR
24+
#endif
2025
#ifdef HAVE_SYS_SELECT_H
2126
# include <sys/select.h> // select()
2227
#endif
28+
#ifdef HAVE_SYS_STAT_H
29+
# include <sys/stat.h> // fstat()
30+
#endif
31+
#ifdef HAVE_UNISTD_H
32+
# include <unistd.h> // ftruncate()
33+
#endif
2334

2435
#if defined(HAVE_SETLOCALE)
2536
/* GNU readline() mistakenly sets the LC_CTYPE locale.
@@ -319,6 +330,176 @@ readline_read_history_file_impl(PyObject *module, PyObject *filename_obj)
319330

320331
static int _history_length = -1; /* do not truncate history by default */
321332

333+
#ifndef __APPLE__
334+
/* macOS libedit carries a patch fixing the bug this works around. */
335+
/* Return libedit's default history file, "~/.history". This must resolve
336+
* the same file as libedit's private _default_history_file()
337+
* (https://cvsweb.netbsd.org/bsdweb.cgi/src/lib/libedit/readline.c) so that we
338+
* truncate the same file write_history() and append_history() just wrote: the
339+
* home directory comes from the password database, $HOME is not consulted.
340+
* The result must be freed with PyMem_RawFree(). */
341+
static char *
342+
_py_libedit_default_history_file(void)
343+
{
344+
struct passwd *pw = NULL;
345+
char *path = NULL;
346+
#ifdef HAVE_GETPWUID_R
347+
struct passwd pwd;
348+
char *buf = NULL;
349+
Py_ssize_t bufsize = sysconf(_SC_GETPW_R_SIZE_MAX);
350+
if (bufsize == -1) {
351+
bufsize = 1024;
352+
}
353+
for (;;) {
354+
char *newbuf = PyMem_RawRealloc(buf, bufsize);
355+
if (newbuf == NULL) {
356+
break;
357+
}
358+
buf = newbuf;
359+
int status = getpwuid_r(getuid(), &pwd, buf, bufsize, &pw);
360+
if (status == 0) {
361+
break;
362+
}
363+
pw = NULL;
364+
if (status != ERANGE || bufsize > (PY_SSIZE_T_MAX >> 1)) {
365+
break;
366+
}
367+
bufsize <<= 1;
368+
}
369+
#else
370+
pw = getpwuid(getuid());
371+
#endif
372+
if (pw != NULL && pw->pw_dir != NULL) {
373+
size_t len = strlen(pw->pw_dir) + sizeof("/.history");
374+
path = PyMem_RawMalloc(len);
375+
if (path != NULL) {
376+
PyOS_snprintf(path, len, "%s/.history", pw->pw_dir);
377+
}
378+
}
379+
#ifdef HAVE_GETPWUID_R
380+
PyMem_RawFree(buf);
381+
#endif
382+
return path;
383+
}
384+
385+
/* libedit's history_truncate_file() keeps the last nlines lines of the
386+
* file, which drops the "_HiStOrY_V2_" header line that its own
387+
* write_history() emits and that its read_history() requires: once
388+
* truncated, the file can no longer be loaded (gh-123018). Truncate the
389+
* file ourselves and keep the header.
390+
*
391+
* Upstream libedit (NetBSD, and the portable releases from thrysoee.dk)
392+
* still has this bug, reported as https://gnats.netbsd.org/60322. Apple's
393+
* libedit fork patches its history_truncate_file() to copy the first line,
394+
* so this workaround is not compiled on macOS. We only keep the header
395+
* when it is actually there, so a file without one (e.g. written by GNU
396+
* readline) is not given a bogus header.
397+
*
398+
* History files are small, so the whole file is read into memory and
399+
* rewritten in place, as libedit does. GNU readline instead writes a
400+
* temporary file and renames it over the original. */
401+
static int
402+
_py_libedit_history_truncate_file(const char *filename, int nlines)
403+
{
404+
static const char cookie[] = "_HiStOrY_V2_\n";
405+
const size_t cookie_len = sizeof(cookie) - 1;
406+
char *default_file = NULL;
407+
char *buf = NULL;
408+
FILE *fp = NULL;
409+
int ret = -1;
410+
411+
if (filename == NULL) {
412+
default_file = _py_libedit_default_history_file();
413+
if (default_file == NULL) {
414+
return -1;
415+
}
416+
filename = default_file;
417+
}
418+
int fd = _Py_open_noraise(filename, O_RDWR);
419+
if (fd < 0) {
420+
goto done;
421+
}
422+
fp = fdopen(fd, "r+");
423+
if (fp == NULL) {
424+
close(fd);
425+
goto done;
426+
}
427+
struct stat st;
428+
if (fstat(fd, &st) != 0) {
429+
goto done;
430+
}
431+
size_t size = (size_t)st.st_size;
432+
buf = PyMem_RawMalloc(size);
433+
if (buf == NULL || fread(buf, 1, size, fp) != size) {
434+
goto done;
435+
}
436+
437+
size_t header = 0;
438+
if (size >= cookie_len && memcmp(buf, cookie, cookie_len) == 0) {
439+
header = cookie_len;
440+
}
441+
const char *end = buf + size;
442+
443+
/* Count the lines following the header. */
444+
size_t total = 0;
445+
for (const char *p = buf + header;
446+
(p = memchr(p, '\n', end - p)) != NULL;
447+
p++) {
448+
total++;
449+
}
450+
if (end > buf + header && end[-1] != '\n') {
451+
total++; /* an unterminated last line */
452+
}
453+
if (total <= (size_t)nlines) {
454+
ret = 0; /* nothing to drop */
455+
goto done;
456+
}
457+
458+
/* Skip the leading lines, keeping the header and the last nlines. */
459+
const char *tail = buf + header;
460+
for (size_t skip = total - (size_t)nlines; skip > 0; skip--) {
461+
const char *nl = memchr(tail, '\n', end - tail);
462+
if (nl == NULL) {
463+
tail = end; /* the unterminated last line is dropped too */
464+
break;
465+
}
466+
tail = nl + 1;
467+
}
468+
size_t taillen = end - tail;
469+
if (fseek(fp, 0, SEEK_SET) != 0
470+
|| fwrite(buf, 1, header, fp) != header
471+
|| fwrite(tail, 1, taillen, fp) != taillen
472+
|| fflush(fp) != 0
473+
|| ftruncate(fd, (off_t)(header + taillen)) != 0) {
474+
goto done;
475+
}
476+
ret = 0;
477+
478+
done:
479+
if (fp != NULL) {
480+
fclose(fp);
481+
}
482+
PyMem_RawFree(buf);
483+
PyMem_RawFree(default_file);
484+
return ret;
485+
}
486+
#endif /* !__APPLE__ */
487+
488+
/* Truncate the history file after write_history() or append_history().
489+
* Like the history_truncate_file() calls this replaces, failures are
490+
* ignored by the callers: the history was already saved successfully and
491+
* a failed truncation only leaves the file longer than requested. */
492+
static int
493+
_py_history_truncate_file(const char *filename, int nlines)
494+
{
495+
#ifndef __APPLE__
496+
if (using_libedit_emulation) {
497+
return _py_libedit_history_truncate_file(filename, nlines);
498+
}
499+
#endif
500+
return history_truncate_file(filename, nlines);
501+
}
502+
322503
/* Exported function to save a readline history file */
323504

324505
/*[clinic input]
@@ -360,7 +541,7 @@ readline_write_history_file_impl(PyObject *module, PyObject *filename_obj)
360541
errno = err = write_history(filename);
361542
int history_length = FT_ATOMIC_LOAD_INT_RELAXED(_history_length);
362543
if (!err && history_length >= 0)
363-
history_truncate_file(filename, history_length);
544+
_py_history_truncate_file(filename, history_length);
364545
Py_XDECREF(filename_bytes);
365546
errno = err;
366547
if (errno)
@@ -419,7 +600,7 @@ readline_append_history_file_impl(PyObject *module, int nelements,
419600
nelements - libedit_append_replace_history_offset, filename);
420601
int history_length = FT_ATOMIC_LOAD_INT_RELAXED(_history_length);
421602
if (!err && history_length >= 0)
422-
history_truncate_file(filename, history_length);
603+
_py_history_truncate_file(filename, history_length);
423604
Py_XDECREF(filename_bytes);
424605
errno = err;
425606
if (errno)

0 commit comments

Comments
 (0)