Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
967160c
feat(core): reject the undeclared save-policy fallback as a declarati…
mberrys Sep 14, 2026
366a972
chore(policy): run the save-policy suites in the core gate lane (#239)
mberrys Sep 14, 2026
3e21784
feat(core): add save-policy strength comparison and one refusal messa…
mberrys Sep 14, 2026
d0e23dc
feat(core): refuse a weakened save policy at the transaction boundary…
mberrys Sep 14, 2026
14f7577
feat(core): enforce the effective save policy on the candidate write …
mberrys Sep 14, 2026
9a71772
feat(core): refuse candidate saves that would overwrite the trusted s…
mberrys Sep 14, 2026
f4fad3b
test(core): pin source bytes across success, cancel, and failure (#239)
mberrys Sep 14, 2026
9ae4792
test(core): pin the received input as a read-only artifact identity (…
mberrys Sep 14, 2026
42e8ee1
test(core): pin that no non-incremental operation is ever appended (#…
mberrys Sep 14, 2026
ea83892
feat(cli): refuse an in-place redaction of the trusted input (#239)
mberrys Sep 14, 2026
17b647b
chore(qualification): add a signed-fixture generator for the incremen…
mberrys Sep 14, 2026
6eca835
test(core): add a real signed incremental-save fixture with provenanc…
mberrys Sep 14, 2026
0f00539
test(core): prove a signed byte range survives an incremental edit (#…
mberrys Sep 14, 2026
27d369b
test(core): pin that incremental cost follows changed data (#239)
mberrys Sep 14, 2026
d6d568d
test(core): emit the signed incremental artifact for independent vali…
mberrys Sep 14, 2026
b3c3fbb
feat(cli): honour the declared save policy in add-bleed and rgb-to-cm…
mberrys Sep 14, 2026
c994755
test(cli): pin the corrective-command save-policy refusals (#239)
mberrys Sep 14, 2026
14fe330
ci: validate the signed incremental artifact with qpdf and pdfsig (#239)
mberrys Sep 14, 2026
351c2c7
test(core): pin that no save path records an approved output (#239)
mberrys Sep 14, 2026
37984f0
docs: state the true state of editor crash recovery (#239)
mberrys Sep 14, 2026
56060a9
docs: document operation-owned save policy strength and its proofs (#…
mberrys Sep 14, 2026
55ec58f
chore(changelog): record the save-policy strength change (#239)
mberrys Sep 14, 2026
dfbf73e
fix(docs): keep the recovery record inside the Loop identity contract…
mberrys Sep 14, 2026
f04e1cc
merge(dev): resolve agent-policy.json conflict for #582
cursoragent Sep 16, 2026
d9543dd
chore(docs): refresh phase5-widgets inventory after dev merge
cursoragent Sep 16, 2026
2046296
fix(test): unwrap PdfTool envelope in bleed stress preflight
cursoragent Sep 16, 2026
3729d73
docs(changes): note bleed stress envelope test fix in #582 fragment
cursoragent Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/reusable-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,44 @@ jobs:
run: |
ctest --output-on-failure

- name: Install independent validators
if: ${{ !inputs.fast }}
run: |
sudo apt update
sudo apt install -y qpdf poppler-utils

- name: Prove signed incremental save with independent validators
if: ${{ !inputs.fast }}
working-directory: loop
env:
QT_QPA_PLATFORM: offscreen
run: |
set -euo pipefail
export LOOP_SAVE_POLICY_EVIDENCE_DIR="$PWD/evidence/save-policy"
rm -rf "$LOOP_SAVE_POLICY_EVIDENCE_DIR"
ctest --test-dir build -R '^UnitTestsIncrementalSave$' --output-on-failure
mkdir -p docs/evidence/session-15-save-policy
python3 scripts/qualification/run_independent_validators.py \
--input "$LOOP_SAVE_POLICY_EVIDENCE_DIR/incremental-with-signature.pdf" \
--output docs/evidence/session-15-save-policy/independent-validation-linux.json \
--candidate-sha "$(git rev-parse HEAD)" \
--claim structural --claim signature
python3 - <<'PY'
import json
data = json.load(open("docs/evidence/session-15-save-policy/independent-validation-linux.json"))
assert data["status"] == "passed", data["status"]
assert all(v["status"] == "passed" for v in data["validators"]), data["validators"]
print("independent validation evidence: passed", [v["claim"] for v in data["validators"]])
PY

- name: Upload save-policy independent validation evidence
if: ${{ !inputs.fast }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: save-policy-independent-validation
path: loop/docs/evidence/session-15-save-policy/independent-validation-linux.json
if-no-files-found: error

- name: Run preflight corpus gate
if: ${{ !inputs.fast }}
working-directory: loop/build
Expand Down
25 changes: 25 additions & 0 deletions LoopLibCore/sources/pdfdocumentwriter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#include <QFile>
#include <QBuffer>
#include <QCryptographicHash>
#include <QFileInfo>
#include <QSaveFile>

#include "pdfdbgheap.h"
Expand Down Expand Up @@ -865,4 +866,28 @@ QByteArray PDFDocumentWriter::getSerializedObject(const PDFObject& object)
return buffer.data();
}

PDFOperationResult validateSaveRequest(const PDFSaveRequest& request)
{
if (request.requestedExplicitly && savePolicyIsWeaker(request.requested, request.required))
{
return PDFOperationResult(savePolicyWeakenedMessage(request.requested, request.required));
}

if (request.appendInPlace || request.sourcePath.isEmpty() || request.outputPath.isEmpty())
{
return PDFOperationResult(true);
}

const QFileInfo sourceInfo(request.sourcePath);
const QFileInfo outputInfo(request.outputPath);
if (sourceInfo.exists() && outputInfo.exists() &&
sourceInfo.canonicalFilePath() == outputInfo.canonicalFilePath())
{
return PDFOperationResult(
QStringLiteral("Refused save: '%1' is the trusted input artifact; write the candidate to a new path.")
.arg(sourceInfo.fileName()));
}
return PDFOperationResult(true);
}

} // namespace pdf
6 changes: 6 additions & 0 deletions LoopLibCore/sources/pdfdocumentwriter.h
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ class LOOPLIBCORESHARED_EXPORT PDFDocumentWriter
const PDFOperationControl* m_operationControl = nullptr;
};

/// Refuses a save request that would weaken the operation-declared policy or
/// overwrite the trusted input artifact. Declared here because the writer is
/// the save boundary; implemented once so the transaction and every CLI write
/// path share the same rule.
LOOPLIBCORESHARED_EXPORT PDFOperationResult validateSaveRequest(const PDFSaveRequest& request);

} // namespace pdf

#endif // PDFDOCUMENTWRITER_H
63 changes: 63 additions & 0 deletions LoopLibCore/sources/pdfrepairoperation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
// SOFTWARE.

#include "pdfrepairoperation.h"
#include "pdfdocumentwriter.h"

#include <algorithm>
#include <utility>
Expand Down Expand Up @@ -373,6 +374,13 @@ PDFOperationResult PDFRepairTransaction::add(const PDFRepairOperation* operation

PDFOperationResult PDFRepairTransaction::analyze()
{
const PDFOperationResult savePolicyRefusal = refuseWeakenedSavePolicy();
if (!savePolicyRefusal)
{
m_status = PDFRepairStatus::Failed;
return savePolicyRefusal;
}

m_plans.clear();
m_results.clear();
m_analyzed = true;
Expand Down Expand Up @@ -416,6 +424,13 @@ PDFOperationResult PDFRepairTransaction::analyze()

PDFOperationResult PDFRepairTransaction::apply()
{
const PDFOperationResult savePolicyRefusal = refuseWeakenedSavePolicy();
if (!savePolicyRefusal)
{
m_status = PDFRepairStatus::Failed;
return savePolicyRefusal;
}

if (!m_analyzed)
{
const PDFOperationResult analysisResult = analyze();
Expand Down Expand Up @@ -486,6 +501,27 @@ PDFOperationResult PDFRepairTransaction::serializeCandidate(const QString& candi
{
return PDFOperationResult(QStringLiteral("Repair transaction has no candidate."));
}
const PDFOperationResult savePolicyRefusal = refuseWeakenedSavePolicy();
if (!savePolicyRefusal)
{
return savePolicyRefusal;
}

const PDFOperationSavePolicy effective =
m_hasRequestedSavePolicy ? m_requestedSavePolicy : savePolicy();
PDFSaveRequest request;
request.sourcePath = m_options.sourcePath;
request.outputPath = candidatePath;
request.required = savePolicy();
request.requested = effective;
request.requestedExplicitly = m_hasRequestedSavePolicy;
request.appendInPlace = effective.mode == PDFSaveMode::IncrementalAppend;
const PDFOperationResult saveRequestRefusal = validateSaveRequest(request);
if (!saveRequestRefusal)
{
return saveRequestRefusal;
}

return PDFRepairDiffEngine::buildSerializedCandidate(
m_candidate,
[](PDFDocument*)
Expand All @@ -505,6 +541,33 @@ PDFOperationSavePolicy PDFRepairTransaction::savePolicy() const
return result;
}

PDFOperationResult PDFRepairTransaction::refuseWeakenedSavePolicy() const
{
if (m_savePolicyRefused ||
(m_hasRequestedSavePolicy && savePolicyIsWeaker(m_requestedSavePolicy, savePolicy())))
{
return PDFOperationResult(savePolicyWeakenedMessage(m_requestedSavePolicy, savePolicy()));
}
return PDFOperationResult(true);
}

PDFOperationResult PDFRepairTransaction::setRequestedSavePolicy(const PDFOperationSavePolicy& policy)
{
const PDFOperationSavePolicy required = savePolicy();
if (savePolicyIsWeaker(policy, required))
{
// The refused request is kept only so every later refusal names the
// same request; the effective policy stays the declared one.
m_requestedSavePolicy = policy;
m_savePolicyRefused = true;
m_status = PDFRepairStatus::Failed;
return PDFOperationResult(savePolicyWeakenedMessage(policy, required));
}
m_requestedSavePolicy = policy;
m_hasRequestedSavePolicy = true;
return PDFOperationResult(true);
}

PDFRepairExpectedChanges PDFRepairTransaction::expectedChanges() const
{
PDFRepairExpectedChanges expected;
Expand Down
21 changes: 19 additions & 2 deletions LoopLibCore/sources/pdfrepairoperation.h
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,11 @@ class LOOPLIBCORESHARED_EXPORT PDFRepairOperation
virtual PDFRepairDomains domains() const = 0;
/// Declares the serialization and signature consequences of this operation.
/// The conservative default prevents an unclassified operation from being
/// appended to a signed or revisioned source.
/// appended to a signed or revisioned source. A registered operation must
/// override this; the registry-wide test rejects the undeclared default.
virtual PDFOperationSavePolicy savePolicy() const
{
return PDFOperationSavePolicy::saveAsNewArtifact(QStringLiteral("operation did not declare a save policy"));
return PDFOperationSavePolicy::undeclared();
}
/// Unknown or incomplete impact forces full revalidation.
virtual PDFOperationImpact impact() const
Expand Down Expand Up @@ -262,6 +263,10 @@ struct LOOPLIBCORESHARED_EXPORT PDFRepairTransactionOptions
bool failOnIncompleteValidation = true;
int maxOperations = 100;
const PDFOperationControl* operationControl = nullptr;
/// The trusted input the caller received. When set, a candidate write to
/// this path is refused; an empty value only disables that path check, not
/// the policy check.
QString sourcePath;
};

class LOOPLIBCORESHARED_EXPORT PDFRepairTransaction
Expand All @@ -286,6 +291,11 @@ class LOOPLIBCORESHARED_EXPORT PDFRepairTransaction
const QList<PDFRepairPlan>& plans() const { return m_plans; }
const QList<PDFRepairResult>& results() const { return m_results; }
PDFOperationSavePolicy savePolicy() const;
/// Declares the save policy the caller is asking for. Stricter than the
/// operation-declared policy is allowed; weaker is refused here, before any
/// analysis or mutation, so a surface cannot talk an operation out of its
/// persistence requirement.
PDFOperationResult setRequestedSavePolicy(const PDFOperationSavePolicy& policy);
PDFRepairStatus status() const { return m_status; }

private:
Expand All @@ -297,6 +307,10 @@ class LOOPLIBCORESHARED_EXPORT PDFRepairTransaction

PDFRepairExpectedChanges expectedChanges() const;
QVector<int> affectedPages() const;
/// The single refusal check shared by analyze(), apply() and
/// serializeCandidate(): a refused request stays refused for the life of
/// the transaction, so a caller cannot retry past it.
PDFOperationResult refuseWeakenedSavePolicy() const;

const PDFDocument* m_source = nullptr;
PDFRepairTransactionOptions m_options;
Expand All @@ -307,6 +321,9 @@ class LOOPLIBCORESHARED_EXPORT PDFRepairTransaction
PDFRepairStatus m_status = PDFRepairStatus::Planned;
bool m_analyzed = false;
bool m_hasCandidate = false;
bool m_hasRequestedSavePolicy = false;
bool m_savePolicyRefused = false;
PDFOperationSavePolicy m_requestedSavePolicy;
};

QString pdfRepairStatusName(PDFRepairStatus status);
Expand Down
1 change: 1 addition & 0 deletions LoopLibCore/sources/pdfrepairprimitives.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ class PDFStandardConversionRepair final : public PDFRepairOperation
{
return PDFRepairDomain::Color | PDFRepairDomain::Fonts | PDFRepairDomain::Images | PDFRepairDomain::Metadata | PDFRepairDomain::PageGeometry | PDFRepairDomain::Structure;
}
PDFOperationSavePolicy savePolicy() const override { return PDFOperationSavePolicy::fullRewrite(QStringLiteral("standard conversion removes prior content")); }
PDFOperationImpact impact(const PDFDocument*, const QJsonObject&) const override
{
PDFOperationImpact declared;
Expand Down
68 changes: 64 additions & 4 deletions LoopLibCore/sources/pdfsavepolicy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,21 @@

#include <utility>

#include <QStringList>

namespace pdf
{

const char* getPDFSaveModeName(PDFSaveMode mode)
{
switch (mode)
{
case PDFSaveMode::IncrementalAppend: return "incremental-append";
case PDFSaveMode::FullRewrite: return "full-rewrite";
case PDFSaveMode::SaveAsNewArtifact: return "save-as-new-artifact";
case PDFSaveMode::IncrementalAppend:
return "incremental-append";
case PDFSaveMode::FullRewrite:
return "full-rewrite";
case PDFSaveMode::SaveAsNewArtifact:
return "save-as-new-artifact";
}
return "unknown";
}
Expand Down Expand Up @@ -66,6 +71,16 @@ PDFOperationSavePolicy PDFOperationSavePolicy::saveAsNewArtifact(QString rationa
return policy;
}

PDFOperationSavePolicy PDFOperationSavePolicy::undeclared()
{
return PDFOperationSavePolicy::saveAsNewArtifact(QStringLiteral("operation did not declare a save policy"));
}

bool PDFOperationSavePolicy::isUndeclared() const
{
return rationale == undeclared().rationale;
}

QJsonObject PDFOperationSavePolicy::toJson() const
{
return QJsonObject{
Expand Down Expand Up @@ -97,4 +112,49 @@ PDFOperationSavePolicy mergePDFSavePolicies(const PDFOperationSavePolicy& first,
return result;
}

} // namespace pdf
bool savePolicyIsWeaker(const PDFOperationSavePolicy& candidate, const PDFOperationSavePolicy& required)
{
if (static_cast<int>(candidate.mode) < static_cast<int>(required.mode))
{
return true;
}
// A stronger mode is never weaker, whatever it claims about signatures and
// reversibility: only a same-mode request can understate those.
if (candidate.mode != required.mode)
{
return false;
}
if (required.invalidatesSignatures && !candidate.invalidatesSignatures)
{
return true;
}
return !required.reversibleInSession && candidate.reversibleInSession;
}

QString savePolicyWeakenedMessage(const PDFOperationSavePolicy& candidate,
const PDFOperationSavePolicy& required)
{
const bool sameMode = candidate.mode == required.mode;
QStringList reasons;
if (static_cast<int>(candidate.mode) < static_cast<int>(required.mode))
{
reasons.append(QStringLiteral("mode '%1' is weaker than the operation-declared '%2'")
.arg(QString::fromLatin1(getPDFSaveModeName(candidate.mode)),
QString::fromLatin1(getPDFSaveModeName(required.mode))));
}
if (sameMode && required.invalidatesSignatures && !candidate.invalidatesSignatures)
{
reasons.append(QStringLiteral("signature invalidation is not declared but the operation invalidates signatures"));
}
if (sameMode && !required.reversibleInSession && candidate.reversibleInSession)
{
reasons.append(QStringLiteral("session reversibility is claimed but the operation is not reversible"));
}
if (reasons.isEmpty())
{
return {};
}
return QStringLiteral("Refused save policy: %1.").arg(reasons.join(QStringLiteral("; ")));
}

} // namespace pdf
Loading
Loading