Test for unusual task data (#3770)

* Test for unusual task data

A task might have any combination of keys and values, but Taskwarrior
often assumes that only valid values can occur, and crashes otherwise.
This is highly inconvenient, as it's often impossible to do anything
with the invalid task -- Taskwarrior just fails without modifying it.

So, this is the beginning of some testing for such invalid tasks, with
the goal of making Taskwarrior due something reasonable. In general, an
invalid attribute value is treated as if it was not set. This is not
exhaustive, and there are likely still bugs of this sort, but as we find
them we can fix and add regression tests to this script.

This introduces a new test-only binary that creates a "bare" task using
TaskChampion, avoiding Taskwarrior's efforts to not create "unusual"
tasks.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Dustin J. Mitchell 2025-02-05 08:20:35 -05:00 committed by GitHub
parent e1fc283da5
commit fdb7e5e020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 368 additions and 41 deletions

View file

@ -57,10 +57,6 @@ void TDB2::open_replica(const std::string& location, bool create_if_missing) {
////////////////////////////////////////////////////////////////////////////////
// Add the new task to the replica.
void TDB2::add(Task& task) {
// Validate a task for addition. This is stricter than `task.validate`, as any
// inconsistency is probably user error.
task.validate_add();
// Ensure the task is consistent, and provide defaults if necessary.
// bool argument to validate() is "applyDefault", to apply default values for
// properties not otherwise given.

View file

@ -321,8 +321,8 @@ void Task::setStatus(Task::status status) {
////////////////////////////////////////////////////////////////////////////////
// Determines status of a date attribute.
Task::dateState Task::getDateState(const std::string& name) const {
std::string value = get(name);
if (value.length()) {
time_t value = get_date(name);
if (value > 0) {
Datetime reference(value);
Datetime now;
Datetime today("today");
@ -804,13 +804,16 @@ std::string Task::composeJSON(bool decorate /*= false*/) const {
// Date fields are written as ISO 8601.
if (type == "date") {
Datetime d(i.second);
out << '"' << (i.first == "modification" ? "modified" : i.first)
<< "\":\""
// Date was deleted, do not export parsed empty string
<< (i.second == "" ? "" : d.toISO()) << '"';
time_t epoch = get_date(i.first);
if (epoch != 0) {
Datetime d(i.second);
out << '"' << (i.first == "modification" ? "modified" : i.first)
<< "\":\""
// Date was deleted, do not export parsed empty string
<< (i.second == "" ? "" : d.toISO()) << '"';
++attributes_written;
++attributes_written;
}
}
/*

View file

@ -56,6 +56,11 @@ int CmdAdd::execute(std::string& output) {
// the task is empty, but DOM references can refer to earlier parts of the
// command line, e.g., `task add due:20110101 wait:due`.
task.modify(Task::modReplace, true);
// Validate a task for addition. This is stricter than `task.validate`, as any
// inconsistency is probably user error.
task.validate_add();
Context::getContext().tdb2.add(task);
// Do not display ID 0, users cannot query by that

View file

@ -120,9 +120,11 @@ int CmdInfo::execute(std::string& output) {
description += '\n' + std::string(indent, ' ') +
Datetime(anno.first.substr(11)).toString(dateformatanno) + ' ' + anno.second;
row = view.addRow();
view.set(row, 0, "Description");
view.set(row, 1, description, c);
if (task.has("description")) {
row = view.addRow();
view.set(row, 0, "Description");
view.set(row, 1, description, c);
}
// status
row = view.addRow();
@ -215,64 +217,76 @@ int CmdInfo::execute(std::string& output) {
}
// entry
row = view.addRow();
view.set(row, 0, "Entered");
Datetime dt(task.get_date("entry"));
std::string entry = dt.toString(dateformat);
if (task.has("entry") && task.get_date("entry")) {
row = view.addRow();
view.set(row, 0, "Entered");
Datetime dt(task.get_date("entry"));
std::string entry = dt.toString(dateformat);
std::string age;
auto created = task.get("entry");
if (created.length()) {
Datetime dt(strtoll(created.c_str(), nullptr, 10));
age = Duration(now - dt).formatVague();
std::string age;
auto created = task.get("entry");
if (created.length()) {
Datetime dt(strtoll(created.c_str(), nullptr, 10));
age = Duration(now - dt).formatVague();
}
view.set(row, 1, entry + " (" + age + ')');
}
view.set(row, 1, entry + " (" + age + ')');
auto validDate = [&](const char* prop) {
if (!task.has(prop)) {
return false;
}
if (task.get_date(prop) == 0) {
return false;
}
return true;
};
// wait
if (task.has("wait")) {
if (validDate("wait")) {
row = view.addRow();
view.set(row, 0, "Waiting until");
view.set(row, 1, Datetime(task.get_date("wait")).toString(dateformat));
}
// scheduled
if (task.has("scheduled")) {
if (validDate("scheduled")) {
row = view.addRow();
view.set(row, 0, "Scheduled");
view.set(row, 1, Datetime(task.get_date("scheduled")).toString(dateformat));
}
// start
if (task.has("start")) {
if (validDate("start")) {
row = view.addRow();
view.set(row, 0, "Start");
view.set(row, 1, Datetime(task.get_date("start")).toString(dateformat));
}
// due (colored)
if (task.has("due")) {
if (validDate("due")) {
row = view.addRow();
view.set(row, 0, "Due");
view.set(row, 1, Datetime(task.get_date("due")).toString(dateformat));
}
// end
if (task.has("end")) {
if (validDate("end")) {
row = view.addRow();
view.set(row, 0, "End");
view.set(row, 1, Datetime(task.get_date("end")).toString(dateformat));
}
// until
if (task.has("until")) {
if (validDate("until")) {
row = view.addRow();
view.set(row, 0, "Until");
view.set(row, 1, Datetime(task.get_date("until")).toString(dateformat));
}
// modified
if (task.has("modified")) {
if (validDate("modified")) {
row = view.addRow();
view.set(row, 0, "Last modified");
@ -632,7 +646,11 @@ std::optional<std::string> CmdInfo::formatForInfo(const std::vector<Operation>&
} else {
// Record the last start time for later duration calculation.
if (prop == "start") {
last_start = Datetime(value.value()).toEpoch();
try {
last_start = Datetime(value.value()).toEpoch();
} catch (std::string) {
// ignore invalid dates
}
}
out << format("{1} set to '{2}'.", Lexer::ucFirst(prop),

View file

@ -119,7 +119,7 @@ void CmdModify::checkConsistency(Task &before, Task &after) {
throw std::string("You cannot remove the recurrence from a recurring task.");
if ((before.getStatus() == Task::pending) && (after.getStatus() == Task::pending) &&
(after.get("end") != ""))
(before.get("end") == "") && (after.get("end") != ""))
throw format("Could not modify task {1}. You cannot set an end date on a pending task.",
before.identifier(true));
}

View file

@ -51,7 +51,12 @@ std::string renderAttribute(const std::string& name, const std::string& value,
if (Context::getContext().columns.find(name) != Context::getContext().columns.end()) {
Column* col = Context::getContext().columns[name];
if (col && col->type() == "date" && value != "") {
Datetime d((time_t)strtoll(value.c_str(), nullptr, 10));
int64_t epoch = strtoll(value.c_str(), nullptr, 10);
// Do not try to render an un-parseable value.
if (epoch == 0) {
return value;
}
Datetime d((time_t)epoch);
if (format == "") return d.toString(Context::getContext().config.get("dateformat"));
return d.toString(format);

View file

@ -288,9 +288,11 @@ std::optional<Datetime> getNextRecurrence(Datetime& current, std::string& period
else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'q') {
int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
if (increment <= 0)
throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
increment);
if (increment <= 0) {
Context::getContext().footnote(format(
"Recurrence period '{1}' is equivalent to {2} and hence invalid.", period, increment));
return std::nullopt;
}
m += 3 * increment;
while (m > 12) {
@ -346,8 +348,11 @@ std::optional<Datetime> getNextRecurrence(Datetime& current, std::string& period
// Add the period to current, and we're done.
std::string::size_type idx = 0;
Duration p;
if (!p.parse(period, idx))
throw std::string(format("The recurrence value '{1}' is not valid.", period));
if (!p.parse(period, idx)) {
Context::getContext().footnote(
format("Warning: The recurrence value '{1}' is not valid.", period));
return std::nullopt;
}
return checked_add_datetime(current, p.toTime_t());
}