diff --git a/bindings/java/src/org/sleuthkit/datamodel/BlackboardAttribute.java b/bindings/java/src/org/sleuthkit/datamodel/BlackboardAttribute.java index ae799531ab..950bb4b245 100755 --- a/bindings/java/src/org/sleuthkit/datamodel/BlackboardAttribute.java +++ b/bindings/java/src/org/sleuthkit/datamodel/BlackboardAttribute.java @@ -612,6 +612,15 @@ public static final class Type implements Serializable { public static final Type TSK_CORRELATION_TYPE = new Type(157, "TSK_CORRELATION_TYPE", bundle.getString("BlackboardAttribute.tskCorrelationType.text"), TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING); public static final Type TSK_CORRELATION_VALUE = new Type(158, "TSK_CORRELATION_VALUE", bundle.getString("BlackboardAttribute.tskCorrelationValue.text"), TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING); public static final Type TSK_OTHER_CASES = new Type(159, "TSK_OTHER_CASES", bundle.getString("BlackboardAttribute.tskOtherCases.text"), TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING); + /* + * The note that holds the reasoning behind an analysis result, so that a + * result is self-describing and anything loading it finds the detail without + * having to know the notes feature exists. The value is the note's + * original_note_id and never a revision id, so the attribute is written once + * when the result is created and stays correct when the note is revised. See + * NoteManager.getCurrentRevision(). + */ + public static final Type TSK_NOTE_ID = new Type(160, "TSK_NOTE_ID", bundle.getString("BlackboardAttribute.tskNoteId.text"), TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.LONG); // NOTE: When adding a new standard BlackboardAttribute.Type, add the instance and then add to the STANDARD_TYPES list. /** @@ -767,7 +776,8 @@ public static final class Type implements Serializable { TSK_IS_ADMIN, TSK_CORRELATION_TYPE, TSK_CORRELATION_VALUE, - TSK_OTHER_CASES + TSK_OTHER_CASES, + TSK_NOTE_ID )); private static final long serialVersionUID = 1L; @@ -1544,7 +1554,10 @@ public enum ATTRIBUTE_TYPE { TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING), TSK_OTHER_CASES(159, "TSK_OTHER_CASES", bundle.getString("BlackboardAttribute.tskOtherCases.text"), - TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING),; + TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING), + TSK_NOTE_ID(160, "TSK_NOTE_ID", + bundle.getString("BlackboardAttribute.tskNoteId.text"), + TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.LONG),; private final int typeID; private final String typeName; diff --git a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties index 4b14a76ff6..1fdb992b15 100644 --- a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties +++ b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties @@ -224,6 +224,7 @@ BlackboardAttribute.tskIsAdmin.text=Is Administrator BlackboardAttribute.tskCorrelationType.text=Correlation Type BlackboardAttribute.tskCorrelationValue.text=Correlation Value BlackboardAttribute.tskOtherCases.text=Other Cases +BlackboardAttribute.tskNoteId.text=Note AbstractFile.readLocal.exception.msg4.text=Error reading local file\: {0} AbstractFile.readLocal.exception.msg1.text=Error reading local file, local path is not set AbstractFile.readLocal.exception.msg2.text=Error reading local file, it does not exist at local path\: {0} @@ -459,5 +460,6 @@ TskData.ObjectType.Report.name=Report TskData.ObjectType.Pool.name=Pool TskData.ObjectType.OsAccount.name=OS Account TskData.ObjectType.HostAddress.name=Host Address +TskData.ObjectType.Case.name=Case TskData.ObjectType.Unsupported.name=Unsupported diff --git a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED index 4b14a76ff6..1fdb992b15 100644 --- a/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED +++ b/bindings/java/src/org/sleuthkit/datamodel/Bundle.properties-MERGED @@ -224,6 +224,7 @@ BlackboardAttribute.tskIsAdmin.text=Is Administrator BlackboardAttribute.tskCorrelationType.text=Correlation Type BlackboardAttribute.tskCorrelationValue.text=Correlation Value BlackboardAttribute.tskOtherCases.text=Other Cases +BlackboardAttribute.tskNoteId.text=Note AbstractFile.readLocal.exception.msg4.text=Error reading local file\: {0} AbstractFile.readLocal.exception.msg1.text=Error reading local file, local path is not set AbstractFile.readLocal.exception.msg2.text=Error reading local file, it does not exist at local path\: {0} @@ -459,5 +460,6 @@ TskData.ObjectType.Report.name=Report TskData.ObjectType.Pool.name=Pool TskData.ObjectType.OsAccount.name=OS Account TskData.ObjectType.HostAddress.name=Host Address +TskData.ObjectType.Case.name=Case TskData.ObjectType.Unsupported.name=Unsupported diff --git a/bindings/java/src/org/sleuthkit/datamodel/CaseDatabaseFactory.java b/bindings/java/src/org/sleuthkit/datamodel/CaseDatabaseFactory.java index df82929a5c..0022d98d6d 100644 --- a/bindings/java/src/org/sleuthkit/datamodel/CaseDatabaseFactory.java +++ b/bindings/java/src/org/sleuthkit/datamodel/CaseDatabaseFactory.java @@ -182,6 +182,7 @@ private void addTables(Connection conn) throws TskCoreException { createFileTables(stmt); createArtifactTables(stmt); createAnalysisResultsTables(stmt); + createNoteTables(stmt); createTagTables(stmt); createIngestTables(stmt); createEventTables(stmt); @@ -341,6 +342,52 @@ private void createAnalysisResultsTables(Statement stmt) throws SQLException { + ")"); } + // Must be called after createTskObjects() and createAnalysisResultsTables(). + private void createNoteTables(Statement stmt) throws SQLException { + + // Note types are open. The Sleuth Kit seeds the built-in ones on every case + // open and consumers add their own by name at runtime, so adding a type does + // not need a schema change. The type carries no behaviour: whether a machine + // wrote a note is answered by tsk_notes.author_kind on the row, never here, + // because a type such as chat has rows from both people and models. + stmt.execute("CREATE TABLE tsk_note_types (note_type_id " + dbQueryHelper.getPrimaryKey() + " PRIMARY KEY, " + + "type_name TEXT NOT NULL UNIQUE, " // COMMENT, AI_ENRICHMENT, REMEDIATION, AI_SUMMARY, ... + + "display_name TEXT, " + + "description TEXT)"); + + // References tsk_objects, tsk_note_types, tsk_analysis_results + // Text about an object in the case that has no score and can change after it is + // written: comments, AI enrichment, remediation advice, summaries. Analysis + // results must not change, which is why this is a separate table rather than + // more columns on one - a finding that can be quietly rewritten is not evidence. + // The table is append-only: an edit inserts a new row carrying the same + // original_note_id and clears is_current on the row it replaces. + stmt.execute("CREATE TABLE tsk_notes (note_id " + dbQueryHelper.getPrimaryKey() + " PRIMARY KEY, " + + "obj_id " + dbQueryHelper.getBigIntType() + " NOT NULL, " // file, artifact, data source, or the case object + + "data_source_obj_id " + dbQueryHelper.getBigIntType() + ", " // derived from obj_id; null for a case level note + + "note_type_id " + dbQueryHelper.getBigIntType() + " NOT NULL, " + + "body TEXT NOT NULL, " // the prose a person reads + + "details TEXT, " // structured payload as JSON; The Sleuth Kit never parses it + + "author_kind INTEGER NOT NULL, " // USER/AI/MODULE; the only answer to "did a machine write this" + + "author_id TEXT NOT NULL, " // stable id of the writer: a user id, a model id, or a module name + + "author_display TEXT NOT NULL, " // what the UI renders + + "config_id TEXT, " // prompt or module configuration version; null for people + + "created_time " + dbQueryHelper.getBigIntType() + " NOT NULL, " // epoch MILLIS, since comment ordering needs sub-second resolution + + "parent_note_id " + dbQueryHelper.getBigIntType() + ", " // note this one replies to; null on a thread root + + "root_note_id " + dbQueryHelper.getBigIntType() + ", " // root of the thread; own note_id on a root, set just after the insert + + "original_note_id " + dbQueryHelper.getBigIntType() + ", " // stable id across edits; own note_id on a first version, set just after the insert + + "is_current INTEGER NOT NULL DEFAULT 1, " // boolean, the live revision of this note + + "is_deleted INTEGER NOT NULL DEFAULT 0, " // boolean, retracted but kept so replies stay reachable + + "analysis_result_id " + dbQueryHelper.getBigIntType() + ", " // the scored finding this note explains + + "FOREIGN KEY(obj_id) REFERENCES tsk_objects(obj_id) ON DELETE CASCADE, " + + "FOREIGN KEY(data_source_obj_id) REFERENCES tsk_objects(obj_id) ON DELETE CASCADE, " + + "FOREIGN KEY(note_type_id) REFERENCES tsk_note_types(note_type_id), " + + "FOREIGN KEY(parent_note_id) REFERENCES tsk_notes(note_id) ON DELETE CASCADE, " + + "FOREIGN KEY(root_note_id) REFERENCES tsk_notes(note_id), " + + "FOREIGN KEY(original_note_id) REFERENCES tsk_notes(note_id), " + + "FOREIGN KEY(analysis_result_id) REFERENCES tsk_analysis_results(artifact_obj_id) ON DELETE SET NULL)"); + } + private void createTagTables(Statement stmt) throws SQLException { stmt.execute("CREATE TABLE tsk_tag_sets (tag_set_id " + dbQueryHelper.getPrimaryKey() + " PRIMARY KEY, name TEXT UNIQUE)"); stmt.execute("CREATE TABLE tag_names (tag_name_id " + dbQueryHelper.getPrimaryKey() + " PRIMARY KEY, display_name TEXT UNIQUE, " @@ -419,7 +466,22 @@ private void addIndexes(Connection conn) throws TskCoreException { stmt.execute("CREATE INDEX tsk_os_account_realms_realm_name_idx ON tsk_os_account_realms(realm_name)"); stmt.execute("CREATE INDEX tsk_os_account_realms_realm_addr_idx ON tsk_os_account_realms(realm_addr)"); - + + // note indexes + stmt.execute("CREATE INDEX tsk_notes_obj_id_created_index ON tsk_notes(obj_id, created_time)"); + stmt.execute("CREATE INDEX tsk_notes_datasrc_type_index ON tsk_notes(data_source_obj_id, note_type_id)"); + stmt.execute("CREATE INDEX tsk_notes_root_index ON tsk_notes(root_note_id)"); + stmt.execute("CREATE INDEX tsk_notes_original_index ON tsk_notes(original_note_id, is_current)"); + + // Makes two current revisions of one note impossible rather than merely unlikely. + // The revision flip - clear the old current row, set the new one - is a + // check-then-act with no lock behind it on PostgreSQL, so enforcing it here + // means the second writer gets a constraint violation it can retry rather than + // every call site having to remember. Partial on is_current so the index holds + // one entry per note rather than one per revision; SQLite has supported partial + // indexes since 3.8.0, so this one is not PostgreSQL-only. + stmt.execute("CREATE UNIQUE INDEX tsk_notes_current_revision_index ON tsk_notes(original_note_id) WHERE is_current = 1"); + } catch (SQLException ex) { throw new TskCoreException("Error initializing db_info tables", ex); } @@ -819,6 +881,9 @@ void performPostTableInitialization(Connection conn) throws TskCoreException { // is carried as a payload column so extension filters are satisfied without a heap fetch. Not partial, // so it covers files of any size and the same index serves name lookups regardless of file size. stmt.execute("CREATE INDEX tsk_files_datasrc_name_size_index ON tsk_files(data_source_obj_id, name, size, extension)"); + + // Most notes are not tied to a finding, so this one is partial. + stmt.execute("CREATE INDEX tsk_notes_ar_partial_index ON tsk_notes(analysis_result_id) WHERE analysis_result_id IS NOT NULL"); } catch (SQLException ex) { throw new TskCoreException("Error performing PostgreSQL post table initialization", ex); } @@ -906,6 +971,10 @@ void performPostTableInitialization(Connection conn) throws TskCoreException { // is carried as a payload column so extension filters are satisfied without a heap fetch. Not partial, // so it covers files of any size and the same index serves name lookups regardless of file size. stmt.execute("CREATE INDEX tsk_files_datasrc_name_size_index ON tsk_files(data_source_obj_id, name, size, extension)"); + + // The PostgreSQL variant of this index is partial on analysis_result_id IS NOT + // NULL. SQLite is kept full here for consistency with the indexes above. + stmt.execute("CREATE INDEX tsk_notes_ar_index ON tsk_notes(analysis_result_id)"); } catch (SQLException ex) { throw new TskCoreException("Error performing SQLite post table initialization", ex); } diff --git a/bindings/java/src/org/sleuthkit/datamodel/Note.java b/bindings/java/src/org/sleuthkit/datamodel/Note.java new file mode 100644 index 0000000000..0b0ef132ff --- /dev/null +++ b/bindings/java/src/org/sleuthkit/datamodel/Note.java @@ -0,0 +1,408 @@ +/* + * Sleuth Kit Data Model + * + * Copyright 2026 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.datamodel; + +import java.util.Objects; +import java.util.Optional; + +/** + * Text that belongs to an object in the case, has no score, and can change + * after it is written. Comments, AI enrichment, remediation advice and + * summaries are all notes. + * + * Notes are append-only. Editing one does not rewrite it: a new row is inserted + * carrying the same original note id and the previous row stops being the + * current revision. Every revision in a lineage therefore shares one stable id, + * which is what outside references (the TSK_NOTE_ID attribute) point at. + * + * Instances are immutable snapshots of a row. Use NoteManager to create, + * revise and read them. + */ +public final class Note { + + /** + * What kind of principal wrote a note. This is the only place the question + * "did a machine write this" is answered, and it is answered per row: a + * chat thread is one note type whose rows have both human and model + * authors, so a type-level flag cannot say. + */ + public enum AuthorKind { + + USER(0, "User"), ///< A person + AI(1, "AI"), ///< A model + MODULE(2, "Module"); ///< Automation that is not a model, such as an ingest module + + private final int id; + private final String name; + + private AuthorKind(int id, String name) { + this.id = id; + this.name = name; + } + + /** + * Gets the id of this author kind, as stored in the author_kind column. + * + * @return The id. + */ + public int getId() { + return id; + } + + /** + * Gets the name of this author kind. + * + * @return The name. + */ + public String getName() { + return name; + } + + /** + * Gets the author kind with the given id. + * + * @param id The id to look for. + * + * @return The author kind. + * + * @throws IllegalArgumentException if the id matches no author kind. + */ + public static AuthorKind fromID(int id) { + for (AuthorKind kind : AuthorKind.values()) { + if (kind.id == id) { + return kind; + } + } + throw new IllegalArgumentException("No AuthorKind matching id: " + id); + } + } + + /** + * Who wrote a note, recorded inline on the note rather than as a key into + * another table. A case database is an evidence container that gets copied, + * archived and reported on, so attribution has to survive on its own, and + * it is a point-in-time fact rather than a live lookup that shifts when + * someone is renamed. + * + * The id is whatever the writing product uses to identify a principal - an + * Autopsy login name, a Cyber Triage user id, a model id, a module name. + * There is one writing product per case database, so the id space is not + * shared and needs no namespace prefix. + */ + public static final class Author { + + private final AuthorKind kind; + private final String id; + private final String displayName; + private final String configId; + + /** + * Constructs an author with no configuration version, which is the + * normal case for a person. + * + * @param kind The kind of principal. + * @param id Stable id of the principal. Required. + * @param displayName What the UI renders for the principal. Required. + */ + public Author(AuthorKind kind, String id, String displayName) { + this(kind, id, displayName, null); + } + + /** + * Constructs an author. + * + * @param kind The kind of principal. + * @param id Stable id of the principal. Required. + * @param displayName What the UI renders for the principal. Required. + * @param configId Version of the prompt or module configuration that + * produced the note, so a bad answer can be told + * from an old one. May be null. + */ + public Author(AuthorKind kind, String id, String displayName, String configId) { + if (kind == null) { + throw new IllegalArgumentException("Author kind is required"); + } + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("Author id is required"); + } + if (displayName == null || displayName.isEmpty()) { + throw new IllegalArgumentException("Author display name is required"); + } + this.kind = kind; + this.id = id; + this.displayName = displayName; + this.configId = configId; + } + + /** + * Gets the kind of principal that wrote the note. + * + * @return The author kind. + */ + public AuthorKind getKind() { + return kind; + } + + /** + * Gets the stable id of the principal that wrote the note. + * + * @return The author id. + */ + public String getId() { + return id; + } + + /** + * Gets the name to render for the principal that wrote the note. + * + * @return The display name. + */ + public String getDisplayName() { + return displayName; + } + + /** + * Gets the prompt or module configuration version that produced the + * note. + * + * @return Optional with the configuration id, empty if there is none. + */ + public Optional getConfigId() { + return Optional.ofNullable(configId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Author)) { + return false; + } + Author other = (Author) obj; + return kind == other.kind + && id.equals(other.id) + && displayName.equals(other.displayName) + && Objects.equals(configId, other.configId); + } + + @Override + public int hashCode() { + return Objects.hash(kind, id, displayName, configId); + } + } + + private final long noteId; + private final long objId; + private final Long dataSourceObjId; + private final NoteType type; + private final String body; + private final String details; + private final Author author; + private final long createdTime; + private final Long parentNoteId; + private final long rootNoteId; + private final long originalNoteId; + private final boolean isCurrent; + private final boolean isDeleted; + private final Long analysisResultId; + + /** + * Constructs a note from a persisted row. + * + * @param noteId Id of this row. + * @param objId Object the note is about. + * @param dataSourceObjId Data source the object belongs to, null for a + * case level note. + * @param type Note type. + * @param body The prose a person reads. + * @param details Structured payload as JSON, may be null. The + * Sleuth Kit never parses it. + * @param author Who wrote it. + * @param createdTime Creation time, epoch milliseconds. + * @param parentNoteId Note this one replies to, null on a thread root. + * @param rootNoteId Root of the thread, own note id on a root. + * @param originalNoteId First version of this note, own note id on a + * first version. + * @param isCurrent True if this is the live revision of the lineage. + * @param isDeleted True if this note has been retracted. + * @param analysisResultId The scored finding this note explains, null if it + * explains none. + */ + Note(long noteId, long objId, Long dataSourceObjId, NoteType type, String body, String details, + Author author, long createdTime, Long parentNoteId, long rootNoteId, long originalNoteId, + boolean isCurrent, boolean isDeleted, Long analysisResultId) { + this.noteId = noteId; + this.objId = objId; + this.dataSourceObjId = dataSourceObjId; + this.type = type; + this.body = body; + this.details = details; + this.author = author; + this.createdTime = createdTime; + this.parentNoteId = parentNoteId; + this.rootNoteId = rootNoteId; + this.originalNoteId = originalNoteId; + this.isCurrent = isCurrent; + this.isDeleted = isDeleted; + this.analysisResultId = analysisResultId; + } + + /** + * Gets the id of this revision. This changes every time the note is + * revised. Anything that needs to refer to the note across edits should use + * getOriginalNoteId() instead. + * + * @return The note id. + */ + public long getNoteId() { + return noteId; + } + + /** + * Gets the object this note is about. It may be a file, an artifact, an + * analysis result, a data source or the case object. + * + * @return The object id. + */ + public long getObjectId() { + return objId; + } + + /** + * Gets the data source the object belongs to. Derived by NoteManager when + * the note is written, not supplied by the caller. + * + * @return Optional with the data source object id, empty for a note on the + * case object or on anything else outside a data source. + */ + public Optional getDataSourceObjectId() { + return Optional.ofNullable(dataSourceObjId); + } + + /** + * Gets the type of this note. + * + * @return The note type. + */ + public NoteType getType() { + return type; + } + + /** + * Gets the prose a person reads. + * + * @return The body. + */ + public String getBody() { + return body; + } + + /** + * Gets the structured payload that goes with the prose, as JSON. The Sleuth + * Kit stores it and never parses it. + * + * @return Optional with the details, empty if there are none. + */ + public Optional getDetails() { + return Optional.ofNullable(details); + } + + /** + * Gets who wrote this note. + * + * @return The author. + */ + public Author getAuthor() { + return author; + } + + /** + * Gets the creation time of this revision, in epoch milliseconds. + * Milliseconds rather than seconds because ordering collaborative comments + * needs sub-second resolution. Ties break on note id. + * + * @return The creation time. + */ + public long getCreatedTime() { + return createdTime; + } + + /** + * Gets the note this one replies to. + * + * @return Optional with the parent note id, empty on a thread root. + */ + public Optional getParentNoteId() { + return Optional.ofNullable(parentNoteId); + } + + /** + * Gets the root of the thread this note belongs to. A thread root is its + * own root. Reading a whole thread is one indexed query on this value. + * + * @return The root note id. + */ + public long getRootNoteId() { + return rootNoteId; + } + + /** + * Gets the stable id of this note across edits. A first version is its own + * original. This is the id an analysis result's TSK_NOTE_ID attribute + * points at, so the attribute stays correct when the note is revised. + * + * @return The original note id. + */ + public long getOriginalNoteId() { + return originalNoteId; + } + + /** + * Indicates whether this is the live revision of its lineage. A partial + * unique index makes two current revisions of one note impossible. + * + * @return True if this is the current revision. + */ + public boolean isCurrent() { + return isCurrent; + } + + /** + * Indicates whether this note has been retracted with a soft delete. The + * row is kept so that replies stay reachable. + * + * @return True if the note is deleted. + */ + public boolean isDeleted() { + return isDeleted; + } + + /** + * Gets the scored finding whose reasoning this note holds. This is not the + * same as a note anchored on a finding through getObjectId(), which is + * someone discussing the finding rather than explaining it. + * + * @return Optional with the analysis result object id, empty if this note + * explains no finding. + */ + public Optional getAnalysisResultId() { + return Optional.ofNullable(analysisResultId); + } +} diff --git a/bindings/java/src/org/sleuthkit/datamodel/NoteManager.java b/bindings/java/src/org/sleuthkit/datamodel/NoteManager.java new file mode 100644 index 0000000000..f1d69bbf2c --- /dev/null +++ b/bindings/java/src/org/sleuthkit/datamodel/NoteManager.java @@ -0,0 +1,1463 @@ +/* + * Sleuth Kit Data Model + * + * Copyright 2026 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.datamodel; + +import com.google.common.collect.Lists; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.sleuthkit.datamodel.SleuthkitCase.CaseDbConnection; +import org.sleuthkit.datamodel.SleuthkitCase.CaseDbTransaction; +import org.sleuthkit.datamodel.TskData.DbType; + +/** + * Responsible for creating, revising, deleting and retrieving Notes. + * + * Reads do not filter. Superseded revisions and soft-deleted rows come back + * along with everything else and the caller decides what to render, so no + * method quietly drops a row. The narrower reads - getCurrentNotes(), + * getCurrentRevision(), getCurrentNoteCounts() - are opt-in. + * + * This manager enforces no permissions. It exposes the author kind and author + * id so a consumer can decide who may act, and that is the whole contract. The + * one rule here that looks like policy, reviseNote() requiring the same author, + * is not: it holds a property of the data, that every revision in a lineage has + * one author, so that "who wrote this" has a single answer no matter which + * revision you are looking at. + */ +public final class NoteManager { + + /** + * Maximum number of notes per PostgreSQL batch chunk in addNotes(). + * Internal chunking unit; callers may pass any number of requests and the + * manager partitions. + * + * Sized to keep the tsk_notes INSERT (13 bound columns per row) under + * PostgreSQL's 65,535 bind-parameter ceiling: 4000 rows = 52,000 + * parameters. + */ + static final int PG_NOTES_CHUNK_SIZE = 4000; + + /** + * Maximum reply depth in a thread. SQL cannot express this, so the manager + * has to, alongside the cycle check in checkThreadDepth(). + */ + private static final int MAX_THREAD_DEPTH = 100; + + /** + * The bound columns of an insert, in order. original_note_id is left to its + * default of NULL and back-filled, and is_current / is_deleted take their + * column defaults. + */ + private static final String NOTE_INSERT_COLUMNS + = "obj_id, data_source_obj_id, note_type_id, body, details, " + + "author_kind, author_id, author_display, config_id, created_time, " + + "parent_note_id, root_note_id, analysis_result_id"; + + private static final String NOTE_INSERT_PLACEHOLDERS = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + private static final int NOTE_INSERT_PARAM_COUNT = 13; + + /** + * Reads join the type table rather than caching it, so a type added by + * another client of the same PostgreSQL case database is never missing. + */ + private static final String NOTE_SELECT + = "SELECT notes.note_id, notes.obj_id, notes.data_source_obj_id, notes.note_type_id, " + + "notes.body, notes.details, notes.author_kind, notes.author_id, notes.author_display, " + + "notes.config_id, notes.created_time, notes.parent_note_id, notes.root_note_id, " + + "notes.original_note_id, notes.is_current, notes.is_deleted, notes.analysis_result_id, " + + "types.type_name, types.display_name, types.description " + + "FROM tsk_notes notes " + + "INNER JOIN tsk_note_types types ON notes.note_type_id = types.note_type_id "; + + /** + * Oldest first, ties broken on note id. created_time is milliseconds, but + * two notes written in the same millisecond still need a stable order. + */ + private static final String NOTE_ORDER = " ORDER BY notes.created_time, notes.note_id"; + + /** + * What a delete does to the row. The schema supports both; which one a user + * gets is the consumer's ruling. + */ + public enum DeleteMode { + + /** + * Remove the rows. This takes the whole revision lineage of the note + * and, through the parent_note_id cascade, its reply subtree with it. + */ + HARD, + /** + * Mark the note deleted and keep the row, so replies stay reachable and + * the UI can show a tombstone. + */ + SOFT; + } + + private final SleuthkitCase db; + + /** + * Construct a NoteManager for the given SleuthkitCase. + * + * @param skCase The SleuthkitCase. + */ + NoteManager(SleuthkitCase skCase) { + this.db = skCase; + } + + /** + * Get the note type with the given name, adding it if it does not exist + * yet. Note types are open: a consumer does not have to wait on a Sleuth + * Kit release to add one. + * + * If the type already exists the existing row is returned unchanged, so the + * display name given here only takes effect when the type is created. + * + * @param typeName Unique name of the type. Required. + * @param displayName Name to render for the type, may be null. + * + * @return The note type. + * + * @throws TskCoreException + */ + public NoteType getOrAddNoteType(String typeName, String displayName) throws TskCoreException { + return getOrAddNoteType(typeName, displayName, null); + } + + /** + * Get the note type with the given name, adding it if it does not exist + * yet. + * + * If the type already exists the existing row is returned unchanged, so the + * display name and description given here only take effect when the type is + * created. + * + * @param typeName Unique name of the type. Required. + * @param displayName Name to render for the type, may be null. + * @param description Description of the type, may be null. + * + * @return The note type. + * + * @throws TskCoreException + */ + public NoteType getOrAddNoteType(String typeName, String displayName, String description) throws TskCoreException { + if (typeName == null || typeName.isEmpty()) { + throw new TskCoreException("Illegal argument passed to getOrAddNoteType: type name is required."); + } + + db.acquireSingleUserCaseWriteLock(); + try (CaseDbConnection connection = db.getConnection()) { + // Insert-then-select rather than select-then-insert. On PostgreSQL two + // clients can open the same case at once, so a check-then-act here would + // race; the UNIQUE constraint on type_name settles it instead. + String insertSql = "INTO tsk_note_types (type_name, display_name, description) VALUES (?, ?, ?)"; + switch (db.getDatabaseType()) { + case POSTGRESQL: + insertSql = "INSERT " + insertSql + " ON CONFLICT DO NOTHING"; //NON-NLS + break; + case SQLITE: + insertSql = "INSERT OR IGNORE " + insertSql; + break; + default: + throw new TskCoreException("Unknown DB Type: " + db.getDatabaseType().name()); + } + + PreparedStatement insert = connection.getPreparedStatement(insertSql, Statement.NO_GENERATED_KEYS); + insert.clearParameters(); + insert.setString(1, typeName); + insert.setString(2, displayName); + insert.setString(3, description); + connection.executeUpdate(insert); + + return getNoteType(typeName, connection).orElseThrow(() + -> new TskCoreException(String.format("Error reading back note type with name = %s", typeName))); + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error adding note type with name = %s", typeName), ex); + } finally { + db.releaseSingleUserCaseWriteLock(); + } + } + + /** + * Get the note type with the given name. + * + * @param typeName Name of the type to look for. + * + * @return Optional with the note type. Optional.empty if no type with that + * name exists. + * + * @throws TskCoreException + */ + public Optional getNoteType(String typeName) throws TskCoreException { + if (typeName == null) { + throw new TskCoreException("Illegal argument passed to getNoteType: type name is required."); + } + + try (CaseDbConnection connection = db.getConnection()) { + return getNoteType(typeName, connection); + } + } + + /** + * Get the note type with the given name. + * + * @param typeName Name of the type to look for. + * @param connection Database connection to use. + * + * @return Optional with the note type. Optional.empty if no type with that + * name exists. + * + * @throws TskCoreException + */ + private Optional getNoteType(String typeName, CaseDbConnection connection) throws TskCoreException { + String queryString = "SELECT note_type_id, type_name, display_name, description FROM tsk_note_types WHERE type_name = ?"; + + db.acquireSingleUserCaseReadLock(); + try { + PreparedStatement statement = connection.getPreparedStatement(queryString, Statement.NO_GENERATED_KEYS); + statement.clearParameters(); + statement.setString(1, typeName); + + try (ResultSet rs = statement.executeQuery()) { + if (!rs.next()) { + return Optional.empty(); + } + return Optional.of(getNoteTypeFromResultSet(rs)); + } + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error getting note type with name = %s", typeName), ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Get all note types, both the built-in ones and any a consumer has added. + * + * @return The note types. + * + * @throws TskCoreException + */ + public List getNoteTypes() throws TskCoreException { + String queryString = "SELECT note_type_id, type_name, display_name, description FROM tsk_note_types ORDER BY type_name"; + + List types = new ArrayList<>(); + db.acquireSingleUserCaseReadLock(); + try (CaseDbConnection connection = db.getConnection(); + Statement s = connection.createStatement(); + ResultSet rs = connection.executeQuery(s, queryString)) { + + while (rs.next()) { + types.add(getNoteTypeFromResultSet(rs)); + } + return types; + } catch (SQLException ex) { + throw new TskCoreException("Error getting note types", ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Add a note, in its own transaction. + * + * @param request The note to add. + * + * @return The note as written, with its derived columns filled in. + * + * @throws TskCoreException + */ + public Note addNote(NoteRequest request) throws TskCoreException { + CaseDbTransaction trans = db.beginTransaction(); + try { + Note note = addNote(request, trans); + trans.commit(); + trans = null; + return note; + } finally { + if (trans != null) { + trans.rollback(); + } + } + } + + /** + * Add a note as part of the caller's transaction. + * + * This delegates to addNotes() rather than duplicating the insert, so the + * single-note and batch paths cannot drift on how data_source_obj_id, + * root_note_id and original_note_id are derived. + * + * @param request The note to add. + * @param trans Transaction to use. + * + * @return The note as written, with its derived columns filled in. + * + * @throws TskCoreException + */ + public Note addNote(NoteRequest request, CaseDbTransaction trans) throws TskCoreException { + if (request == null) { + throw new TskCoreException("Illegal argument passed to addNote: request is required."); + } + return addNotes(Collections.singletonList(request), trans).get(0); + } + + /** + * Add notes as part of the caller's transaction. + * + * A batch is one transaction: if any request is invalid nothing is written + * and the exception names the offending index. One event is fired for the + * whole batch, after the caller commits. + * + * On PostgreSQL the requests are partitioned at PG_NOTES_CHUNK_SIZE and + * each chunk is written as one multi-row INSERT. On SQLite, whose in-process + * driver makes cross-row batching unprofitable, each row is inserted on its + * own. Both engines then run the same back-fill statement, so the derived + * columns are set by one piece of SQL rather than two. + * + * A reply must name a note that already exists, so a batch cannot build a + * thread in one call. The write this is for is one answer applied to many + * items, which is many thread roots rather than one thread. + * + * @param requests The notes to add. May be empty. Must not be null. + * @param trans Transaction to use. + * + * @return The notes as written, in request order. + * + * @throws TskCoreException + */ + public List addNotes(List requests, CaseDbTransaction trans) throws TskCoreException { + return addNotes(requests, trans, db.getDatabaseType() == DbType.POSTGRESQL); + } + + /** + * Add notes as part of the caller's transaction, choosing how the rows are + * written rather than letting the engine decide. + * + * The multi-row INSERT is not PostgreSQL specific SQL, only the decision to + * prefer it is, so this exists to let the test suite run the batched path + * against SQLite and check it against the row at a time path. Production + * code should call the two argument form. + * + * @param requests The notes to add. May be empty. Must not be null. + * @param trans Transaction to use. + * @param batched True to write each chunk as one multi-row INSERT, false + * to insert a row at a time. + * + * @return The notes as written, in request order. + * + * @throws TskCoreException + */ + List addNotes(List requests, CaseDbTransaction trans, boolean batched) throws TskCoreException { + if (requests == null) { + throw new TskCoreException("Illegal argument passed to addNotes: requests list is required."); + } + if (trans == null) { + throw new TskCoreException("Illegal argument passed to addNotes: transaction is required."); + } + if (requests.isEmpty()) { + return Collections.emptyList(); + } + + CaseDbConnection connection = trans.getConnection(); + + // Validate and resolve the derived columns for every request before writing + // anything, so a bad request fails the batch rather than half-writing it. + List pending = prepareNotes(requests, connection); + + List noteIds = new ArrayList<>(pending.size()); + if (batched) { + for (List chunk : Lists.partition(pending, PG_NOTES_CHUNK_SIZE)) { + noteIds.addAll(insertNotesBatched(chunk, connection)); + } + } else { + for (PendingNote note : pending) { + noteIds.add(insertNote(note, connection)); + } + } + + backFillSelfReferences(noteIds, connection); + + List notes = new ArrayList<>(pending.size()); + for (int i = 0; i < pending.size(); i++) { + notes.add(pending.get(i).toNote(noteIds.get(i))); + } + trans.registerAddedNotes(notes); + return notes; + } + + /** + * Revise a note, in its own transaction. + * + * @param noteId Id of the revision being replaced. It must be the current + * revision of its lineage. + * @param body The new prose. Required. + * @param details The new structured payload, may be null. + * @param author Who is revising. The author id must match the note's, and + * the config id may have moved on. + * + * @return The new current revision. + * + * @throws TskCoreException + */ + public Note reviseNote(long noteId, String body, String details, Note.Author author) throws TskCoreException { + CaseDbTransaction trans = db.beginTransaction(); + try { + Note revision = reviseNote(noteId, body, details, author, trans); + trans.commit(); + trans = null; + return revision; + } finally { + if (trans != null) { + trans.rollback(); + } + } + } + + /** + * Revise a note as part of the caller's transaction. + * + * Nothing is rewritten in place. The previous revision keeps its row and + * stops being current, and a new row is inserted carrying the same original + * note id, so the earlier text survives and anything pointing at the note + * still resolves. The partial unique index on (original_note_id) where + * is_current = 1 makes it impossible for two writers to both leave the + * lineage with a current revision; the loser gets a constraint violation it + * can retry. + * + * You revise your own note. You reply to someone else's - there is no + * operation that rewrites another person's words under their name. + * + * @param noteId Id of the revision being replaced. It must be the current + * revision of its lineage. + * @param body The new prose. Required. + * @param details The new structured payload, may be null. + * @param author Who is revising. The author id must match the note's, and + * the config id may have moved on. + * @param trans Transaction to use. + * + * @return The new current revision. + * + * @throws TskCoreException + */ + public Note reviseNote(long noteId, String body, String details, Note.Author author, CaseDbTransaction trans) throws TskCoreException { + if (body == null) { + throw new TskCoreException("Illegal argument passed to reviseNote: body is required."); + } + if (author == null) { + throw new TskCoreException("Illegal argument passed to reviseNote: author is required."); + } + if (trans == null) { + throw new TskCoreException("Illegal argument passed to reviseNote: transaction is required."); + } + + CaseDbConnection connection = trans.getConnection(); + Note existing = getNoteById(noteId, connection).orElseThrow(() + -> new TskCoreException(String.format("Cannot revise note with id = %d, it does not exist", noteId))); + + if (!existing.isCurrent()) { + throw new TskCoreException(String.format("Cannot revise note with id = %d, it has already been superseded. " + + "Use getCurrentRevision(%d) to find the revision to revise.", noteId, existing.getOriginalNoteId())); + } + if (existing.isDeleted()) { + // Without this the new row would take the is_deleted default of 0 and quietly + // bring a retracted note back. A retraction stands; write a new note instead. + throw new TskCoreException(String.format("Cannot revise note with id = %d, it has been deleted.", noteId)); + } + if (!existing.getAuthor().getId().equals(author.getId())) { + throw new TskCoreException(String.format("Cannot revise note with id = %d, it was written by a different author. " + + "Reply to it instead.", noteId)); + } + + try { + // Clear the old revision first. Doing it the other way round would put two + // current rows in the lineage for the length of a statement, which the + // unique index rejects. + PreparedStatement clear = connection.getPreparedStatement( + "UPDATE tsk_notes SET is_current = 0 WHERE note_id = ?", Statement.NO_GENERATED_KEYS); + clear.clearParameters(); + clear.setLong(1, noteId); + connection.executeUpdate(clear); + + String insertSql = "INSERT INTO tsk_notes (" + NOTE_INSERT_COLUMNS + ", original_note_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + PreparedStatement insert = connection.getPreparedStatement(insertSql, Statement.RETURN_GENERATED_KEYS); + insert.clearParameters(); + + PendingNote revision = new PendingNote(existing.getObjectId(), + existing.getDataSourceObjectId().orElse(null), existing.getType(), body, details, author, + System.currentTimeMillis(), existing.getParentNoteId().orElse(null), existing.getRootNoteId(), + existing.getAnalysisResultId().orElse(null)); + setNoteParameters(insert, revision); + insert.setLong(NOTE_INSERT_PARAM_COUNT + 1, existing.getOriginalNoteId()); + connection.executeUpdate(insert); + + long revisionId; + try (ResultSet rs = insert.getGeneratedKeys()) { + if (!rs.next()) { + throw new TskCoreException(String.format("Error reading back the revision of note with id = %d", noteId)); + } + revisionId = rs.getLong(1); + } + + Note note = revision.toNote(revisionId, existing.getOriginalNoteId()); + trans.registerUpdatedNote(note); + return note; + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error revising note with id = %d", noteId), ex); + } + } + + /** + * Delete a note, in its own transaction. + * + * @param noteId Id of the note to delete. + * @param mode Whether to remove the rows or mark the note deleted. + * + * @throws TskCoreException + */ + public void deleteNote(long noteId, DeleteMode mode) throws TskCoreException { + deleteNotes(Collections.singletonList(noteId), mode); + } + + /** + * Delete notes, in one transaction. + * + * Both modes act on the whole revision lineage of each named note, so it + * does not matter whether the caller holds a revision id or the stable + * original note id - the id an analysis result's TSK_NOTE_ID attribute + * carries. A hard delete removes the lineage and, through the + * parent_note_id cascade, the replies underneath it; it has to take the + * whole lineage, since the later revisions reference the first one and + * deleting that row on its own would be a foreign key violation. A soft + * delete marks the lineage deleted and leaves the rows, and the replies, + * alone. + * + * The fired event names the notes the caller asked to delete, not the other + * rows that went with them. + * + * @param noteIds Ids of the notes to delete. May be empty. + * @param mode Whether to remove the rows or mark the notes deleted. + * + * @throws TskCoreException + */ + public void deleteNotes(Collection noteIds, DeleteMode mode) throws TskCoreException { + if (noteIds == null) { + throw new TskCoreException("Illegal argument passed to deleteNotes: note ids are required."); + } + if (mode == null) { + throw new TskCoreException("Illegal argument passed to deleteNotes: delete mode is required."); + } + if (noteIds.isEmpty()) { + return; + } + + List requested = new ArrayList<>(new LinkedHashSet<>(noteIds)); + CaseDbTransaction trans = db.beginTransaction(); + try { + CaseDbConnection connection = trans.getConnection(); + try (Statement s = connection.createStatement()) { + for (List chunk : Lists.partition(requested, PG_NOTES_CHUNK_SIZE)) { + // Resolve the lineages first, in two steps rather than a subquery on + // the table being written to. Both modes work on lineages, so a caller + // holding either a revision id or the stable original id gets the same + // answer. + List lineageIds = new ArrayList<>(); + try (ResultSet rs = connection.executeQuery(s, "SELECT DISTINCT original_note_id FROM tsk_notes " + + "WHERE note_id IN (" + toIdList(chunk) + ")")) { + while (rs.next()) { + lineageIds.add(rs.getLong(1)); + } + } + if (lineageIds.isEmpty()) { + continue; + } + + if (mode == DeleteMode.SOFT) { + connection.executeUpdate(s, "UPDATE tsk_notes SET is_deleted = 1 WHERE original_note_id IN (" + toIdList(lineageIds) + ")"); + } else { + connection.executeUpdate(s, "DELETE FROM tsk_notes WHERE original_note_id IN (" + toIdList(lineageIds) + ")"); + } + } + } + + trans.registerDeletedNotes(requested); + trans.commit(); + trans = null; + } catch (SQLException ex) { + throw new TskCoreException("Error deleting notes", ex); + } finally { + if (trans != null) { + trans.rollback(); + } + } + } + + /** + * Get every note on an object, of every type. Superseded revisions and + * soft-deleted notes are included. + * + * @param objId The object. + * + * @return The notes, oldest first. + * + * @throws TskCoreException + */ + public List getNotes(long objId) throws TskCoreException { + return getNotes(NOTE_SELECT + "WHERE notes.obj_id = " + objId + NOTE_ORDER, + String.format("Error getting notes for object with id = %d", objId)); + } + + /** + * Get every note of one type on an object. Superseded revisions and + * soft-deleted notes are included. + * + * @param objId The object. + * @param type The note type. Required. + * + * @return The notes, oldest first. + * + * @throws TskCoreException + */ + public List getNotes(long objId, NoteType type) throws TskCoreException { + requireType(type); + return getNotes(NOTE_SELECT + "WHERE notes.obj_id = " + objId + + " AND notes.note_type_id = " + type.getNoteTypeId() + NOTE_ORDER, + String.format("Error getting %s notes for object with id = %d", type.getTypeName(), objId)); + } + + /** + * Get the live notes of one type on an object, that is the current revision + * of each note. This is a list rather than an Optional: nothing guarantees + * one live note per object and type, and two models writing enrichment + * about one item is not an error. + * + * Soft-deleted notes are still included; only superseded revisions are + * dropped. + * + * @param objId The object. + * @param type The note type. Required. + * + * @return The current revisions, oldest first. + * + * @throws TskCoreException + */ + public List getCurrentNotes(long objId, NoteType type) throws TskCoreException { + requireType(type); + return getNotes(NOTE_SELECT + "WHERE notes.obj_id = " + objId + + " AND notes.note_type_id = " + type.getNoteTypeId() + + " AND notes.is_current = 1" + NOTE_ORDER, + String.format("Error getting current %s notes for object with id = %d", type.getTypeName(), objId)); + } + + /** + * Get the live revision of one note. This is what an analysis result's + * TSK_NOTE_ID attribute resolves to, since that attribute holds the + * original note id and never a revision id. + * + * @param originalNoteId The stable id of the note. + * + * @return Optional with the current revision. Optional.empty if no note + * with that original id exists. + * + * @throws TskCoreException + */ + public Optional getCurrentRevision(long originalNoteId) throws TskCoreException { + List notes = getNotes(NOTE_SELECT + "WHERE notes.original_note_id = " + originalNoteId + + " AND notes.is_current = 1", + String.format("Error getting the current revision of note with original id = %d", originalNoteId)); + return notes.isEmpty() ? Optional.empty() : Optional.of(notes.get(0)); + } + + /** + * Get a whole thread in one query. + * + * @param rootNoteId The root of the thread. A thread root is its own root, + * so this is the note id of the first note in the thread. + * + * @return The notes in the thread, oldest first. + * + * @throws TskCoreException + */ + public List getThread(long rootNoteId) throws TskCoreException { + return getNotes(NOTE_SELECT + "WHERE notes.root_note_id = " + rootNoteId + NOTE_ORDER, + String.format("Error getting the thread rooted at note with id = %d", rootNoteId)); + } + + /** + * Get the full edit history of one note. + * + * @param originalNoteId The stable id of the note. + * + * @return Every revision of the note, oldest first. + * + * @throws TskCoreException + */ + public List getRevisions(long originalNoteId) throws TskCoreException { + return getNotes(NOTE_SELECT + "WHERE notes.original_note_id = " + originalNoteId + NOTE_ORDER, + String.format("Error getting the revisions of note with original id = %d", originalNoteId)); + } + + /** + * Get the notes of one type on many objects, in one query per chunk. This + * is what a table of items uses when it needs the notes themselves. + * + * @param objIds The objects. + * @param type The note type. Required. + * + * @return Map of object id to its notes, oldest first. Objects with no + * matching note are absent from the map. + * + * @throws TskCoreException + */ + public Map> getNotes(Collection objIds, NoteType type) throws TskCoreException { + requireType(type); + if (objIds == null) { + throw new TskCoreException("Illegal argument passed to getNotes: object ids are required."); + } + + Map> notesByObject = new HashMap<>(); + for (List chunk : partitionIds(objIds)) { + List notes = getNotes(NOTE_SELECT + "WHERE notes.obj_id IN (" + toIdList(chunk) + ")" + + " AND notes.note_type_id = " + type.getNoteTypeId() + NOTE_ORDER, + String.format("Error getting %s notes for %d objects", type.getTypeName(), chunk.size())); + for (Note note : notes) { + notesByObject.computeIfAbsent(note.getObjectId(), key -> new ArrayList<>()).add(note); + } + } + return notesByObject; + } + + /** + * Count the notes of one type on many objects, without loading any prose. + * A table showing hundreds of items and a note badge on each is the reason + * this exists: reading every body to count them is the obvious N+1 trap. + * + * Like the other broad reads this counts everything, so every revision of a + * note counts separately and retracted notes are included. Use + * getCurrentNoteCounts() for a badge. + * + * @param objIds The objects. + * @param type The note type. Required. + * + * @return Map of object id to note count. Objects with no matching note are + * absent from the map. + * + * @throws TskCoreException + */ + public Map getNoteCounts(Collection objIds, NoteType type) throws TskCoreException { + return getNoteCounts(objIds, type, false); + } + + /** + * Count the live notes of one type on many objects, without loading any + * prose. This is the count a note badge wants: one per note rather than one + * per revision. + * + * This is the exact counterpart of getCurrentNotes(): it counts the same + * rows that method returns, retracted notes included. Whether a retraction + * is shown is the consumer's ruling, and it has to be the same ruling for + * the badge and for the list behind it. + * + * @param objIds The objects. + * @param type The note type. Required. + * + * @return Map of object id to note count. Objects with no matching note are + * absent from the map. + * + * @throws TskCoreException + */ + public Map getCurrentNoteCounts(Collection objIds, NoteType type) throws TskCoreException { + return getNoteCounts(objIds, type, true); + } + + /** + * Get the notes of one type anywhere in a data source. + * + * This finds notes on the objects the manager can place in a data source - + * files, artifacts and the data source itself. Notes on the case object are + * not in any data source and never appear here. + * + * @param dataSourceObjId The data source. + * @param type The note type. Required. + * + * @return The notes, oldest first. + * + * @throws TskCoreException + */ + public List getNotesForDataSource(long dataSourceObjId, NoteType type) throws TskCoreException { + requireType(type); + return getNotes(NOTE_SELECT + "WHERE notes.data_source_obj_id = " + dataSourceObjId + + " AND notes.note_type_id = " + type.getNoteTypeId() + NOTE_ORDER, + String.format("Error getting %s notes for data source with id = %d", type.getTypeName(), dataSourceObjId)); + } + + /** + * Get one note by the id of the revision. + * + * @param noteId The note id. + * + * @return Optional with the note. Optional.empty if there is no such note. + * + * @throws TskCoreException + */ + public Optional getNoteById(long noteId) throws TskCoreException { + try (CaseDbConnection connection = db.getConnection()) { + return getNoteById(noteId, connection); + } + } + + /** + * Get one note by the id of the revision. + * + * @param noteId The note id. + * @param connection Database connection to use. + * + * @return Optional with the note. Optional.empty if there is no such note. + * + * @throws TskCoreException + */ + private Optional getNoteById(long noteId, CaseDbConnection connection) throws TskCoreException { + String queryString = NOTE_SELECT + "WHERE notes.note_id = " + noteId; + + db.acquireSingleUserCaseReadLock(); + try (Statement s = connection.createStatement(); + ResultSet rs = connection.executeQuery(s, queryString)) { + + if (!rs.next()) { + return Optional.empty(); + } + return Optional.of(getNoteFromResultSet(rs)); + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error getting note with id = %d", noteId), ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Run a note query on its own connection. + * + * @param queryString The query, which must select the NOTE_SELECT columns. + * @param errorMessage Message for the exception if the query fails. + * + * @return The notes. + * + * @throws TskCoreException + */ + private List getNotes(String queryString, String errorMessage) throws TskCoreException { + List notes = new ArrayList<>(); + db.acquireSingleUserCaseReadLock(); + try (CaseDbConnection connection = db.getConnection(); + Statement s = connection.createStatement(); + ResultSet rs = connection.executeQuery(s, queryString)) { + + while (rs.next()) { + notes.add(getNoteFromResultSet(rs)); + } + return notes; + } catch (SQLException ex) { + throw new TskCoreException(errorMessage, ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Count the notes of one type on many objects. + * + * @param objIds The objects. + * @param type The note type. + * @param currentOnly True to count only live, undeleted notes. + * + * @return Map of object id to note count. + * + * @throws TskCoreException + */ + private Map getNoteCounts(Collection objIds, NoteType type, boolean currentOnly) throws TskCoreException { + requireType(type); + if (objIds == null) { + throw new TskCoreException("Illegal argument passed to getNoteCounts: object ids are required."); + } + + Map countsByObject = new HashMap<>(); + db.acquireSingleUserCaseReadLock(); + try (CaseDbConnection connection = db.getConnection(); + Statement s = connection.createStatement()) { + + for (List chunk : partitionIds(objIds)) { + String queryString = "SELECT obj_id, COUNT(*) AS count FROM tsk_notes " + + "WHERE obj_id IN (" + toIdList(chunk) + ")" + + " AND note_type_id = " + type.getNoteTypeId() + + (currentOnly ? " AND is_current = 1" : "") + + " GROUP BY obj_id"; + try (ResultSet rs = connection.executeQuery(s, queryString)) { + while (rs.next()) { + countsByObject.put(rs.getLong("obj_id"), rs.getInt("count")); + } + } + } + return countsByObject; + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error counting %s notes", type.getTypeName()), ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Validate the requests and work out the columns the caller does not + * supply. This is the only place the derived columns are computed, so the + * single-note, SQLite batch and PostgreSQL batch paths cannot disagree + * about them. + * + * @param requests The requests, in caller order. + * @param connection Database connection to use. + * + * @return The pending notes, in request order. + * + * @throws TskCoreException if any request is invalid. + */ + private List prepareNotes(List requests, CaseDbConnection connection) throws TskCoreException { + Set objIds = new HashSet<>(); + Set parentNoteIds = new HashSet<>(); + for (int i = 0; i < requests.size(); i++) { + NoteRequest request = requests.get(i); + if (request == null) { + throw new TskCoreException(String.format("Illegal argument passed to addNotes: request at index %d is null.", i)); + } + objIds.add(request.getObjectId()); + request.getParentNoteId().ifPresent(parentNoteIds::add); + } + + Map dataSourceObjIds = getDataSourceObjIds(objIds, connection); + Map parents = getParentNotes(parentNoteIds, connection); + Map> threadsByRoot = new HashMap<>(); + + List pending = new ArrayList<>(requests.size()); + for (int i = 0; i < requests.size(); i++) { + NoteRequest request = requests.get(i); + Long parentNoteId = request.getParentNoteId().orElse(null); + Long rootNoteId = null; + + if (parentNoteId != null) { + ParentNote parent = parents.get(parentNoteId); + if (parent == null) { + throw new TskCoreException(String.format( + "Illegal argument passed to addNotes: request at index %d replies to note with id = %d, which does not exist.", + i, parentNoteId)); + } + if (parent.objId != request.getObjectId()) { + throw new TskCoreException(String.format( + "Illegal argument passed to addNotes: request at index %d is on object with id = %d but replies to a note on " + + "object with id = %d. A thread cannot span objects.", i, request.getObjectId(), parent.objId)); + } + rootNoteId = parent.rootNoteId; + checkThreadDepth(i, parentNoteId, rootNoteId, threadsByRoot, connection); + } + + pending.add(new PendingNote(request.getObjectId(), dataSourceObjIds.get(request.getObjectId()), + request.getType(), request.getBody(), request.getDetails().orElse(null), request.getAuthor(), + request.getCreatedTime(), parentNoteId, rootNoteId, request.getAnalysisResultId().orElse(null))); + } + return pending; + } + + /** + * Find the data source each object belongs to. + * + * Notes are anchored on files, artifacts (analysis results included), data + * sources and the case object. The first three are what this places. The + * case object is not in a data source, and neither are the other root level + * objects - OS accounts, host addresses, reports - so no data source is the + * right answer for them. An object of some other kind comes back with none + * as well, rather than an error, and would then not be found by + * getNotesForDataSource(). + * + * @param objIds The objects. + * @param connection Database connection to use. + * + * @return Map of object id to data source object id, holding only the + * objects that have one. + * + * @throws TskCoreException + */ + private Map getDataSourceObjIds(Set objIds, CaseDbConnection connection) throws TskCoreException { + Map dataSourceObjIds = new HashMap<>(); + db.acquireSingleUserCaseReadLock(); + try (Statement s = connection.createStatement()) { + for (List chunk : partitionIds(objIds)) { + String idList = toIdList(chunk); + // Read the data source columns directly rather than walking par_obj_id up + // to a root: the walk would report the case object as its own data source. + String queryString = "SELECT obj_id, data_source_obj_id FROM tsk_files WHERE obj_id IN (" + idList + ")" + + " UNION ALL " + + "SELECT artifact_obj_id, data_source_obj_id FROM blackboard_artifacts WHERE artifact_obj_id IN (" + idList + ")" + + " UNION ALL " + + "SELECT obj_id, obj_id FROM data_source_info WHERE obj_id IN (" + idList + ")"; + try (ResultSet rs = connection.executeQuery(s, queryString)) { + while (rs.next()) { + long dataSourceObjId = rs.getLong(2); + if (!rs.wasNull()) { + dataSourceObjIds.put(rs.getLong(1), dataSourceObjId); + } + } + } + } + return dataSourceObjIds; + } catch (SQLException ex) { + throw new TskCoreException("Error getting the data sources of the objects being annotated", ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Read the object and thread of the notes being replied to. + * + * @param parentNoteIds The notes being replied to. May be empty. + * @param connection Database connection to use. + * + * @return Map of note id to its object and thread root. + * + * @throws TskCoreException + */ + private Map getParentNotes(Set parentNoteIds, CaseDbConnection connection) throws TskCoreException { + Map parents = new HashMap<>(); + if (parentNoteIds.isEmpty()) { + return parents; + } + + db.acquireSingleUserCaseReadLock(); + try (Statement s = connection.createStatement()) { + for (List chunk : partitionIds(parentNoteIds)) { + String queryString = "SELECT note_id, obj_id, root_note_id FROM tsk_notes WHERE note_id IN (" + toIdList(chunk) + ")"; + try (ResultSet rs = connection.executeQuery(s, queryString)) { + while (rs.next()) { + parents.put(rs.getLong("note_id"), new ParentNote(rs.getLong("obj_id"), rs.getLong("root_note_id"))); + } + } + } + return parents; + } catch (SQLException ex) { + throw new TskCoreException("Error getting the notes being replied to", ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + } + + /** + * Check that adding a reply under the given note keeps the thread inside + * MAX_THREAD_DEPTH, and that walking up from it terminates. A self + * referencing foreign key permits A to B to A and SQL will not stop it, so + * the manager does. Every note in a thread shares a root note id, which + * makes the walk one indexed query. + * + * @param requestIndex Index of the request, for the error message. + * @param parentNoteId The note being replied to. + * @param rootNoteId Root of the thread it belongs to. + * @param threadsByRoot Cache of note id to parent note id, per thread, so a + * batch of replies to one thread reads it once. + * @param connection Database connection to use. + * + * @throws TskCoreException if the reply would be too deep, or if the thread + * contains a cycle. + */ + private void checkThreadDepth(int requestIndex, long parentNoteId, long rootNoteId, + Map> threadsByRoot, CaseDbConnection connection) throws TskCoreException { + + Map parentsInThread = threadsByRoot.get(rootNoteId); + if (parentsInThread == null) { + parentsInThread = new HashMap<>(); + db.acquireSingleUserCaseReadLock(); + try (Statement s = connection.createStatement(); + ResultSet rs = connection.executeQuery(s, + "SELECT note_id, parent_note_id FROM tsk_notes WHERE root_note_id = " + rootNoteId)) { + + while (rs.next()) { + long noteId = rs.getLong("note_id"); + long parent = rs.getLong("parent_note_id"); + if (!rs.wasNull()) { + parentsInThread.put(noteId, parent); + } + } + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error reading the thread rooted at note with id = %d", rootNoteId), ex); + } finally { + db.releaseSingleUserCaseReadLock(); + } + threadsByRoot.put(rootNoteId, parentsInThread); + } + + Set visited = new HashSet<>(); + Long ancestor = parentNoteId; + int depth = 1; + while (ancestor != null) { + if (!visited.add(ancestor)) { + throw new TskCoreException(String.format( + "Illegal argument passed to addNotes: request at index %d replies into a thread that contains a cycle at note with id = %d.", + requestIndex, ancestor)); + } + if (depth > MAX_THREAD_DEPTH) { + throw new TskCoreException(String.format( + "Illegal argument passed to addNotes: request at index %d would exceed the maximum thread depth of %d.", + requestIndex, MAX_THREAD_DEPTH)); + } + ancestor = parentsInThread.get(ancestor); + depth++; + } + } + + /** + * Insert one note and return its generated id. + * + * @param note The note to write. + * @param connection Database connection to use. + * + * @return The generated note id. + * + * @throws TskCoreException + */ + private long insertNote(PendingNote note, CaseDbConnection connection) throws TskCoreException { + String insertSql = "INSERT INTO tsk_notes (" + NOTE_INSERT_COLUMNS + ") VALUES " + NOTE_INSERT_PLACEHOLDERS; + try { + PreparedStatement statement = connection.getPreparedStatement(insertSql, Statement.RETURN_GENERATED_KEYS); + statement.clearParameters(); + setNoteParameters(statement, note); + connection.executeUpdate(statement); + + try (ResultSet rs = statement.getGeneratedKeys()) { + if (!rs.next()) { + throw new TskCoreException(String.format("Error adding note on object with id = %d", note.objId)); + } + return rs.getLong(1); + } + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error adding note on object with id = %d", note.objId), ex); + } + } + + /** + * Insert a chunk of notes as one multi-row INSERT and return their + * generated ids in insertion order. + * + * @param chunk The notes to write, at most PG_NOTES_CHUNK_SIZE of + * them. + * @param connection Database connection to use. + * + * @return The generated note ids, in the order the notes were given. + * + * @throws TskCoreException + */ + private List insertNotesBatched(List chunk, CaseDbConnection connection) throws TskCoreException { + StringBuilder insertSql = new StringBuilder("INSERT INTO tsk_notes (").append(NOTE_INSERT_COLUMNS).append(") VALUES "); + for (int i = 0; i < chunk.size(); i++) { + if (i > 0) { + insertSql.append(", "); + } + insertSql.append(NOTE_INSERT_PLACEHOLDERS); + } + insertSql.append(" RETURNING note_id"); + + // Prepared directly rather than through the connection's ad hoc statement + // cache, which is keyed by SQL text and would hold one entry per chunk size. + try (PreparedStatement statement = connection.getConnection().prepareStatement(insertSql.toString())) { + for (int i = 0; i < chunk.size(); i++) { + setNoteParameters(statement, chunk.get(i), i * NOTE_INSERT_PARAM_COUNT); + } + + List noteIds = new ArrayList<>(chunk.size()); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + noteIds.add(rs.getLong(1)); + } + } + if (noteIds.size() != chunk.size()) { + throw new TskCoreException(String.format("Error adding notes, wrote %d rows but expected %d", + noteIds.size(), chunk.size())); + } + // note_id is a sequence consumed in VALUES order, so sorting ascending gives + // the order the rows were given rather than relying on the order RETURNING + // happens to emit them in. + Collections.sort(noteIds); + return noteIds; + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error adding a batch of %d notes", chunk.size()), ex); + } + } + + /** + * Point the self-referencing columns of newly inserted notes at their own + * rows. They cannot be set on the insert because they hold the row's own + * note id, and pre-allocating that id from the sequence is possible only on + * PostgreSQL, which would give the two engines different mechanisms for the + * two columns the batch paths exist to keep identical. So both engines + * insert them as NULL and run this one statement per batch instead. + * + * A reply already has its root note id, inherited from the note it replies + * to, which is why the root is coalesced rather than overwritten. + * + * @param noteIds The notes just written. + * @param connection Database connection to use. + * + * @throws TskCoreException + */ + private void backFillSelfReferences(List noteIds, CaseDbConnection connection) throws TskCoreException { + try (Statement s = connection.createStatement()) { + for (List chunk : Lists.partition(noteIds, PG_NOTES_CHUNK_SIZE)) { + connection.executeUpdate(s, "UPDATE tsk_notes SET original_note_id = note_id, " + + "root_note_id = COALESCE(root_note_id, note_id) WHERE note_id IN (" + toIdList(chunk) + ")"); + } + } catch (SQLException ex) { + throw new TskCoreException(String.format("Error setting the thread and revision ids of %d new notes", noteIds.size()), ex); + } + } + + /** + * Bind one note onto an insert statement, starting at the first parameter. + * + * @param statement The insert statement. + * @param note The note to bind. + * + * @throws SQLException + */ + private static void setNoteParameters(PreparedStatement statement, PendingNote note) throws SQLException { + setNoteParameters(statement, note, 0); + } + + /** + * Bind one note onto an insert statement. + * + * @param statement The insert statement. + * @param note The note to bind. + * @param offset Number of parameters already bound, so a multi-row + * insert can bind row after row. + * + * @throws SQLException + */ + private static void setNoteParameters(PreparedStatement statement, PendingNote note, int offset) throws SQLException { + statement.setLong(offset + 1, note.objId); + setNullableLong(statement, offset + 2, note.dataSourceObjId); + statement.setLong(offset + 3, note.type.getNoteTypeId()); + statement.setString(offset + 4, note.body); + statement.setString(offset + 5, note.details); + statement.setInt(offset + 6, note.author.getKind().getId()); + statement.setString(offset + 7, note.author.getId()); + statement.setString(offset + 8, note.author.getDisplayName()); + statement.setString(offset + 9, note.author.getConfigId().orElse(null)); + statement.setLong(offset + 10, note.createdTime); + setNullableLong(statement, offset + 11, note.parentNoteId); + setNullableLong(statement, offset + 12, note.rootNoteId); + setNullableLong(statement, offset + 13, note.analysisResultId); + } + + /** + * Bind a long that may be null. + * + * @param statement The statement. + * @param parameterIndex Index of the parameter to bind. + * @param value The value, may be null. + * + * @throws SQLException + */ + private static void setNullableLong(PreparedStatement statement, int parameterIndex, Long value) throws SQLException { + if (value == null) { + statement.setNull(parameterIndex, Types.BIGINT); + } else { + statement.setLong(parameterIndex, value); + } + } + + /** + * Read a long that may be null. + * + * @param rs The result set. + * @param columnName Name of the column to read. + * + * @return The value, or null. + * + * @throws SQLException + */ + private static Long getNullableLong(ResultSet rs, String columnName) throws SQLException { + long value = rs.getLong(columnName); + return rs.wasNull() ? null : value; + } + + /** + * Build a note from a row of a NOTE_SELECT query. + * + * @param rs The result set, positioned on the row. + * + * @return The note. + * + * @throws SQLException + */ + private static Note getNoteFromResultSet(ResultSet rs) throws SQLException { + NoteType type = getNoteTypeFromResultSet(rs); + Note.Author author = new Note.Author(Note.AuthorKind.fromID(rs.getInt("author_kind")), + rs.getString("author_id"), rs.getString("author_display"), rs.getString("config_id")); + + return new Note(rs.getLong("note_id"), rs.getLong("obj_id"), getNullableLong(rs, "data_source_obj_id"), + type, rs.getString("body"), rs.getString("details"), author, rs.getLong("created_time"), + getNullableLong(rs, "parent_note_id"), rs.getLong("root_note_id"), rs.getLong("original_note_id"), + rs.getInt("is_current") != 0, rs.getInt("is_deleted") != 0, getNullableLong(rs, "analysis_result_id")); + } + + /** + * Build a note type from a row that carries the type columns. + * + * @param rs The result set, positioned on the row. + * + * @return The note type. + * + * @throws SQLException + */ + private static NoteType getNoteTypeFromResultSet(ResultSet rs) throws SQLException { + return new NoteType(rs.getLong("note_type_id"), rs.getString("type_name"), + rs.getString("display_name"), rs.getString("description")); + } + + /** + * Reject a missing note type. + * + * @param type The type given by the caller. + * + * @throws TskCoreException if the type is null. + */ + private static void requireType(NoteType type) throws TskCoreException { + if (type == null) { + throw new TskCoreException("Illegal argument passed to NoteManager: note type is required."); + } + } + + /** + * Split a collection of ids into chunks that fit comfortably in an IN + * clause, dropping duplicates. + * + * @param ids The ids. + * + * @return The chunks. + */ + private static List> partitionIds(Collection ids) { + return Lists.partition(new ArrayList<>(new LinkedHashSet<>(ids)), PG_NOTES_CHUNK_SIZE); + } + + /** + * Render ids as the body of an IN clause. They are written as literals + * rather than bound, so that a chunk is one statement whatever the + * parameter ceiling is. + * + * @param ids The ids, which must not be empty. + * + * @return The comma separated ids. + */ + private static String toIdList(Collection ids) { + StringBuilder idList = new StringBuilder(); + for (Long id : ids) { + if (idList.length() > 0) { + idList.append(","); + } + idList.append(id); + } + return idList.toString(); + } + + /** + * A note with its derived columns resolved, ready to be written. + */ + private static final class PendingNote { + + private final long objId; + private final Long dataSourceObjId; + private final NoteType type; + private final String body; + private final String details; + private final Note.Author author; + private final long createdTime; + private final Long parentNoteId; + private final Long rootNoteId; + private final Long analysisResultId; + + PendingNote(long objId, Long dataSourceObjId, NoteType type, String body, String details, + Note.Author author, long createdTime, Long parentNoteId, Long rootNoteId, Long analysisResultId) { + this.objId = objId; + this.dataSourceObjId = dataSourceObjId; + this.type = type; + this.body = body; + this.details = details; + this.author = author; + this.createdTime = createdTime; + this.parentNoteId = parentNoteId; + this.rootNoteId = rootNoteId; + this.analysisResultId = analysisResultId; + } + + /** + * Build the note this became once it was written as a first version, so + * that it is its own original and, if it starts a thread, its own root. + * + * @param noteId The generated note id. + * + * @return The note. + */ + Note toNote(long noteId) { + return toNote(noteId, noteId); + } + + /** + * Build the note this became once it was written. + * + * @param noteId The generated note id. + * @param originalNoteId The lineage this note belongs to. + * + * @return The note. + */ + Note toNote(long noteId, long originalNoteId) { + return new Note(noteId, objId, dataSourceObjId, type, body, details, author, createdTime, + parentNoteId, rootNoteId == null ? noteId : rootNoteId, originalNoteId, true, false, analysisResultId); + } + } + + /** + * The parts of a note being replied to that a reply needs. + */ + private static final class ParentNote { + + private final long objId; + private final long rootNoteId; + + ParentNote(long objId, long rootNoteId) { + this.objId = objId; + this.rootNoteId = rootNoteId; + } + } +} diff --git a/bindings/java/src/org/sleuthkit/datamodel/NoteRequest.java b/bindings/java/src/org/sleuthkit/datamodel/NoteRequest.java new file mode 100644 index 0000000000..2e763996c6 --- /dev/null +++ b/bindings/java/src/org/sleuthkit/datamodel/NoteRequest.java @@ -0,0 +1,168 @@ +/* + * Sleuth Kit Data Model + * + * Copyright 2026 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.datamodel; + +import java.util.Optional; + +/** + * Per-note request data for NoteManager.addNote() and NoteManager.addNotes(). + * + * This carries only what a caller supplies. The derived columns - + * data_source_obj_id, root_note_id and original_note_id - are computed by + * NoteManager and are deliberately not settable here, so that the single-row + * and batch write paths cannot disagree about them. + */ +public final class NoteRequest { + + private final long objId; + private final NoteType type; + private final String body; + private final String details; + private final Note.Author author; + private final Long parentNoteId; + private final Long analysisResultId; + private final long createdTime; + + /** + * Constructs a request for a note with no structured payload, that starts + * its own thread and explains no finding. + * + * @param objId Object the note is about. It may be a file, an artifact, an + * analysis result, a data source or the case object. + * @param type Note type. Required. + * @param body The prose a person reads. Required. + * @param author Who wrote it. Required. + */ + public NoteRequest(long objId, NoteType type, String body, Note.Author author) { + this(objId, type, body, null, author, null, null, null); + } + + /** + * Constructs a request for a note. + * + * @param objId Object the note is about. A reply must name the + * same object as the note it replies to, so that a + * thread cannot span objects. + * @param type Note type. Required. + * @param body The prose a person reads. Required. + * @param details Structured payload as JSON, may be null. The + * Sleuth Kit stores it and never parses it. + * @param author Who wrote it. Required. + * @param parentNoteId Note this one replies to, null to start a thread. + * @param analysisResultId The scored finding whose reasoning this note + * holds, null if it explains none. Pass the + * artifact_obj_id of the analysis result. + * @param createdTime Creation time in epoch milliseconds, null for + * now. + */ + public NoteRequest(long objId, NoteType type, String body, String details, Note.Author author, + Long parentNoteId, Long analysisResultId, Long createdTime) { + if (type == null) { + throw new IllegalArgumentException("Note type is required"); + } + if (body == null) { + throw new IllegalArgumentException("Note body is required"); + } + if (author == null) { + throw new IllegalArgumentException("Note author is required"); + } + this.objId = objId; + this.type = type; + this.body = body; + this.details = details; + this.author = author; + this.parentNoteId = parentNoteId; + this.analysisResultId = analysisResultId; + this.createdTime = (createdTime == null) ? System.currentTimeMillis() : createdTime; + } + + /** + * Gets the object the note is about. + * + * @return The object id. + */ + public long getObjectId() { + return objId; + } + + /** + * Gets the note type. + * + * @return The note type. + */ + public NoteType getType() { + return type; + } + + /** + * Gets the prose a person reads. + * + * @return The body. + */ + public String getBody() { + return body; + } + + /** + * Gets the structured payload that goes with the prose. + * + * @return Optional with the details, empty if there are none. + */ + public Optional getDetails() { + return Optional.ofNullable(details); + } + + /** + * Gets who wrote the note. + * + * @return The author. + */ + public Note.Author getAuthor() { + return author; + } + + /** + * Gets the note this one replies to. + * + * @return Optional with the parent note id, empty if the note starts a + * thread. + */ + public Optional getParentNoteId() { + return Optional.ofNullable(parentNoteId); + } + + /** + * Gets the scored finding whose reasoning this note holds. + * + * @return Optional with the analysis result object id, empty if the note + * explains no finding. + */ + public Optional getAnalysisResultId() { + return Optional.ofNullable(analysisResultId); + } + + /** + * Gets the creation time, in epoch milliseconds. + * + * @return The creation time. + */ + public long getCreatedTime() { + return createdTime; + } +} diff --git a/bindings/java/src/org/sleuthkit/datamodel/NoteType.java b/bindings/java/src/org/sleuthkit/datamodel/NoteType.java new file mode 100644 index 0000000000..25bb280858 --- /dev/null +++ b/bindings/java/src/org/sleuthkit/datamodel/NoteType.java @@ -0,0 +1,163 @@ +/* + * Sleuth Kit Data Model + * + * Copyright 2026 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.datamodel; + +import java.util.Optional; + +/** + * The kind of a note, such as a user comment or an AI summary. + * + * Note types are open, the way artifact types are. The Sleuth Kit seeds the + * built-in types on every case open and callers add their own by name at + * runtime through NoteManager.getOrAddNoteType(), so a consumer does not have + * to wait on a Sleuth Kit release to add a type. + * + * The type carries no behaviour. Anything that changes how a note is treated + * belongs on the note itself: in particular "did a machine write this" is + * answered by Note.AuthorKind on the row, never by the type, because a type + * such as chat has rows from both people and models. + */ +public final class NoteType { + + /** + * The note types The Sleuth Kit ships with. These are seeded on every case + * open; a consumer may add more at runtime by name. + */ + public enum BuiltIn { + + COMMENT("Comment", "A comment written by a person"), + AI_ENRICHMENT("AI Enrichment", "Additional context about an item produced by a model"), + REMEDIATION("Remediation", "Advice on what to do about an item"), + AI_SUMMARY("AI Summary", "A summary of the notable items on a host or on the case"); + + private final String displayName; + private final String description; + + private BuiltIn(String displayName, String description) { + this.displayName = displayName; + this.description = description; + } + + /** + * Gets the type name of this built-in type, which is the enum constant + * name and is what is stored in the type_name column. + * + * @return The type name. + */ + public String getTypeName() { + return name(); + } + + /** + * Gets the display name of this built-in type. + * + * @return The display name. + */ + public String getDisplayName() { + return displayName; + } + + /** + * Gets the description of this built-in type. + * + * @return The description. + */ + public String getDescription() { + return description; + } + } + + private final long noteTypeId; + private final String typeName; + private final String displayName; + private final String description; + + /** + * Constructs a note type from a persisted row. + * + * @param noteTypeId Id of the type. + * @param typeName Unique name of the type. + * @param displayName Name to render, may be null. + * @param description Description of the type, may be null. + */ + NoteType(long noteTypeId, String typeName, String displayName, String description) { + this.noteTypeId = noteTypeId; + this.typeName = typeName; + this.displayName = displayName; + this.description = description; + } + + /** + * Gets the id of this type. + * + * @return The note type id. + */ + public long getNoteTypeId() { + return noteTypeId; + } + + /** + * Gets the unique name of this type. + * + * @return The type name. + */ + public String getTypeName() { + return typeName; + } + + /** + * Gets the name to render for this type. A type can appear that nothing + * renders specially, in which case this is the fallback. + * + * @return Optional with the display name, empty if there is none. + */ + public Optional getDisplayName() { + return Optional.ofNullable(displayName); + } + + /** + * Gets the description of this type. + * + * @return Optional with the description, empty if there is none. + */ + public Optional getDescription() { + return Optional.ofNullable(description); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof NoteType)) { + return false; + } + return noteTypeId == ((NoteType) obj).noteTypeId; + } + + @Override + public int hashCode() { + return Long.hashCode(noteTypeId); + } + + @Override + public String toString() { + return typeName; + } +} diff --git a/bindings/java/src/org/sleuthkit/datamodel/SleuthkitCase.java b/bindings/java/src/org/sleuthkit/datamodel/SleuthkitCase.java index 9073d73df8..1db958ceff 100755 --- a/bindings/java/src/org/sleuthkit/datamodel/SleuthkitCase.java +++ b/bindings/java/src/org/sleuthkit/datamodel/SleuthkitCase.java @@ -113,7 +113,7 @@ public class SleuthkitCase { private static final int MAX_DB_NAME_LEN_BEFORE_TIMESTAMP = 47; static final CaseDbSchemaVersionNumber CURRENT_DB_SCHEMA_VERSION - = new CaseDbSchemaVersionNumber(9, 8); + = new CaseDbSchemaVersionNumber(9, 9); private static final long BASE_ARTIFACT_ID = Long.MIN_VALUE; // Artifact ids will start at the lowest negative value private static final Logger logger = Logger.getLogger(SleuthkitCase.class.getName()); @@ -193,6 +193,7 @@ public class SleuthkitCase { private static final String SCHEMA_MINOR_VERSION_KEY = "SCHEMA_MINOR_VERSION"; private static final String CREATION_SCHEMA_MAJOR_VERSION_KEY = "CREATION_SCHEMA_MAJOR_VERSION"; private static final String CREATION_SCHEMA_MINOR_VERSION_KEY = "CREATION_SCHEMA_MINOR_VERSION"; + private static final String CASE_OBJECT_ID_KEY = "CASE_OBJECT_ID"; // key in acquisition tool settings; the password for decrypting an image static final String IMAGE_PASSWORD_KEY = "imagePassword"; @@ -274,6 +275,11 @@ public class SleuthkitCase { private HostManager hostManager; private PersonManager personManager; private HostAddressManager hostAddressManager; + private NoteManager noteManager; + + // Object id of the row in tsk_objects that stands for the case itself, so that + // case level notes have something to point at. Read or created on every open. + private long caseObjectId; private final Map> deviceIdToDatasourceObjIdMap = new HashMap<>(); @@ -469,11 +475,13 @@ private void init() throws Exception { initReviewStatuses(connection); initEncodingTypes(connection); initCollectedStatusTypes(connection); + initNoteTypes(connection); + initCaseObject(connection); // @@@ UPDATE TO ALLOW CT TO BE ASYNC populateHasChildrenMap(false); updateExaminers(connection); initDBSchemaCreationVersion(connection); - } + } fileManager = new FileManager(this); communicationsMgr = new CommunicationsManager(this); @@ -486,6 +494,7 @@ private void init() throws Exception { hostManager = new HostManager(this); personManager = new PersonManager(this); hostAddressManager = new HostAddressManager(this); + noteManager = new NoteManager(this); } /** @@ -756,6 +765,34 @@ public HostAddressManager getHostAddressManager() throws TskCoreException { return hostAddressManager; } + /** + * Gets the note manager for this case. + * + * @return The per case NoteManager object. + * + * @throws TskCoreException + */ + public NoteManager getNoteManager() throws TskCoreException { + return noteManager; + } + + /** + * Gets the object id of the row in tsk_objects that stands for the case + * itself. It is a root level object, a sibling of the data sources rather + * than their parent, and it exists so that case level content such as a + * summary of the whole incident has an object to hang from. + * + * The case is not exposed as a Content: it is not forensic content, and a + * new Content subtype would mean a new method on the public ContentVisitor + * and SleuthkitItemVisitor interfaces and a coordinated release with every + * implementor. It is a plain object id instead. + * + * @return The object id of the case object. + */ + public long getCaseObjectId() { + return caseObjectId; + } + /** * Initializes the next artifact id. If there are entries in the * blackboard_artifacts table we will use max(artifact_id) + 1 otherwise we @@ -964,6 +1001,146 @@ private void initCollectedStatusTypes(CaseDbConnection connection) throws SQLExc } } + /** + * Put the built-in note types into the table. Note types are open, so this + * seeds the ones The Sleuth Kit ships with and leaves consumers to add + * their own by name at runtime through NoteManager. + * + * On PostgreSQL two clients can open the same case at once and both run + * this, so the insert leans on the UNIQUE constraint on type_name rather + * than checking first. + * + * @throws SQLException + * @throws TskCoreException + */ + private void initNoteTypes(CaseDbConnection connection) throws SQLException, TskCoreException { + // The display names and descriptions are prose, so they are bound rather than + // interpolated. An apostrophe in one of them would otherwise fail every case + // open, and this runs on every open. + String query = "INTO tsk_note_types (type_name, display_name, description) VALUES (?, ?, ?)"; + switch (getDatabaseType()) { + case POSTGRESQL: + query = "INSERT " + query + " ON CONFLICT DO NOTHING"; // NON-NLS + break; + case SQLITE: + query = "INSERT OR IGNORE " + query; + break; + default: + throw new TskCoreException("Unknown DB Type: " + getDatabaseType().name()); + } + + acquireSingleUserCaseWriteLock(); + try { + PreparedStatement statement = connection.getPreparedStatement(query, Statement.NO_GENERATED_KEYS); + for (NoteType.BuiltIn type : NoteType.BuiltIn.values()) { + statement.clearParameters(); + statement.setString(1, type.getTypeName()); + statement.setString(2, type.getDisplayName()); + statement.setString(3, type.getDescription()); + connection.executeUpdate(statement); + } + } finally { + releaseSingleUserCaseWriteLock(); + } + } + + /** + * Get the object id of the case object, creating it if this is the first + * open since the case was created or upgraded to 9.9. + * + * This runs on every open rather than only at creation because it has to. + * On PostgreSQL two clients can open the same case at the same time and + * both find it missing, and acquireSingleUserCaseWriteLock() would not help + * because it is a JVM lock that is real only for single-user SQLite. The + * primary key on tsk_db_info_extended.name settles the race instead: both + * insert, one wins, and both then read back the same id. + * + * @throws SQLException + * @throws TskCoreException + */ + private void initCaseObject(CaseDbConnection connection) throws SQLException, TskCoreException { + acquireSingleUserCaseWriteLock(); + try { + Long existingId = getCaseObjectId(connection); + if (existingId != null) { + caseObjectId = existingId; + return; + } + + // The object row and the row naming it have to land together. This + // connection is otherwise in autocommit, so without a transaction a failure + // after the first insert would leave a parentless CASE object behind that + // nothing points at, and the next open would make another one. + connection.beginTransaction(); + try { + // A null parent, not a self reference. AbstractContent walks up the + // parent chain in four places and stops only when getParent() returns + // null, and getContentById() hands back an UnsupportedContent for a type + // it does not know, so a self-referencing parent would recurse forever + // in anything generic that touched this object. + long newObjId = addObject(0, TskData.ObjectType.CASE.getObjectType(), connection); + + String insertSql = String.format("INTO tsk_db_info_extended (name, value) VALUES ('%s', '%d')", + CASE_OBJECT_ID_KEY, newObjId); + switch (getDatabaseType()) { + case POSTGRESQL: + insertSql = "INSERT " + insertSql + " ON CONFLICT DO NOTHING"; // NON-NLS + break; + case SQLITE: + insertSql = "INSERT OR IGNORE " + insertSql; + break; + default: + throw new TskCoreException("Unknown DB Type: " + getDatabaseType().name()); + } + try (Statement statement = connection.createStatement()) { + statement.execute(insertSql); + } + + Long storedId = getCaseObjectId(connection); + if (storedId == null) { + throw new TskCoreException("Error reading back the case object id from tsk_db_info_extended"); + } + // If another client won the race its object is the case object and the + // row just added to tsk_objects is an orphan. Remove it rather than + // leave a second parentless CASE row behind. + if (storedId.longValue() != newObjId) { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM tsk_objects WHERE obj_id = " + newObjId); + } + } + + connection.commitTransaction(); + caseObjectId = storedId; + } catch (SQLException | TskCoreException ex) { + rollbackTransaction(connection); + throw ex; + } + } finally { + releaseSingleUserCaseWriteLock(); + } + } + + /** + * Read the recorded case object id. + * + * @param connection A case database connection. + * + * @return The case object id, or null if it has not been recorded yet. + * + * @throws SQLException + */ + private Long getCaseObjectId(CaseDbConnection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = connection.executeQuery(statement, + "SELECT value FROM tsk_db_info_extended WHERE name = '" + CASE_OBJECT_ID_KEY + "'")) { + + if (resultSet.next()) { + return Long.valueOf(resultSet.getString("value")); + } + return null; + } + } + /** * Records the current examiner name in the tsk_examiners table * @@ -1183,6 +1360,7 @@ private void updateDatabaseSchema(String dbPath) throws Exception { dbSchemaVersion = updateFromSchema9dot5toSchema9dot6(dbSchemaVersion, connection); dbSchemaVersion = updateFromSchema9dot6toSchema9dot7(dbSchemaVersion, connection); dbSchemaVersion = updateFromSchema9dot7toSchema9dot8(dbSchemaVersion, connection); + dbSchemaVersion = updateFromSchema9dot8toSchema9dot9(dbSchemaVersion, connection); statement = connection.createStatement(); @@ -3122,6 +3300,82 @@ private CaseDbSchemaVersionNumber updateFromSchema9dot7toSchema9dot8(CaseDbSchem } } + private CaseDbSchemaVersionNumber updateFromSchema9dot8toSchema9dot9(CaseDbSchemaVersionNumber schemaVersion, CaseDbConnection connection) throws SQLException, TskCoreException { + if (schemaVersion.getMajor() != 9) { + return schemaVersion; + } + + if (schemaVersion.getMinor() != 8) { + return schemaVersion; + } + + String bigIntDataType = "BIGINT"; + String primaryKeyType = "BIGSERIAL"; + if (this.dbType.equals(DbType.SQLITE)) { + bigIntDataType = "INTEGER"; + primaryKeyType = "INTEGER"; + } + + Statement statement = connection.createStatement(); + acquireSingleUserCaseWriteLock(); + try { + // Notes: text about an object in the case that has no score and can change after + // it is written. See CaseDatabaseFactory.createNoteTables() for the same DDL with + // the full column commentary, and NoteManager for the API over it. + // + // There is no table for the case object. Its id is a database-level singleton + // fact, which is what tsk_db_info_extended already is, and that table exists in + // every 9.x database. The row is written by initCaseObject() on the first open + // after this upgrade, so this method creates the tables and nothing else. + statement.execute("CREATE TABLE tsk_note_types (note_type_id " + primaryKeyType + " PRIMARY KEY, " + + "type_name TEXT NOT NULL UNIQUE, " + + "display_name TEXT, " + + "description TEXT)"); + + statement.execute("CREATE TABLE tsk_notes (note_id " + primaryKeyType + " PRIMARY KEY, " + + "obj_id " + bigIntDataType + " NOT NULL, " + + "data_source_obj_id " + bigIntDataType + ", " + + "note_type_id " + bigIntDataType + " NOT NULL, " + + "body TEXT NOT NULL, " + + "details TEXT, " + + "author_kind INTEGER NOT NULL, " + + "author_id TEXT NOT NULL, " + + "author_display TEXT NOT NULL, " + + "config_id TEXT, " + + "created_time " + bigIntDataType + " NOT NULL, " + + "parent_note_id " + bigIntDataType + ", " + + "root_note_id " + bigIntDataType + ", " + + "original_note_id " + bigIntDataType + ", " + + "is_current INTEGER NOT NULL DEFAULT 1, " + + "is_deleted INTEGER NOT NULL DEFAULT 0, " + + "analysis_result_id " + bigIntDataType + ", " + + "FOREIGN KEY(obj_id) REFERENCES tsk_objects(obj_id) ON DELETE CASCADE, " + + "FOREIGN KEY(data_source_obj_id) REFERENCES tsk_objects(obj_id) ON DELETE CASCADE, " + + "FOREIGN KEY(note_type_id) REFERENCES tsk_note_types(note_type_id), " + + "FOREIGN KEY(parent_note_id) REFERENCES tsk_notes(note_id) ON DELETE CASCADE, " + + "FOREIGN KEY(root_note_id) REFERENCES tsk_notes(note_id), " + + "FOREIGN KEY(original_note_id) REFERENCES tsk_notes(note_id), " + + "FOREIGN KEY(analysis_result_id) REFERENCES tsk_analysis_results(artifact_obj_id) ON DELETE SET NULL)"); + + statement.execute("CREATE INDEX tsk_notes_obj_id_created_index ON tsk_notes(obj_id, created_time)"); + statement.execute("CREATE INDEX tsk_notes_datasrc_type_index ON tsk_notes(data_source_obj_id, note_type_id)"); + statement.execute("CREATE INDEX tsk_notes_root_index ON tsk_notes(root_note_id)"); + statement.execute("CREATE INDEX tsk_notes_original_index ON tsk_notes(original_note_id, is_current)"); + statement.execute("CREATE UNIQUE INDEX tsk_notes_current_revision_index ON tsk_notes(original_note_id) WHERE is_current = 1"); + + if (this.dbType.equals(DbType.SQLITE)) { + statement.execute("CREATE INDEX tsk_notes_ar_index ON tsk_notes(analysis_result_id)"); + } else { + statement.execute("CREATE INDEX tsk_notes_ar_partial_index ON tsk_notes(analysis_result_id) WHERE analysis_result_id IS NOT NULL"); + } + + return new CaseDbSchemaVersionNumber(9, 9); + } finally { + closeStatement(statement); + releaseSingleUserCaseWriteLock(); + } + } + /** * Inserts a row for the given account type in account_types table, if one * doesn't exist. @@ -3876,6 +4130,12 @@ public List getRootObjects() throws TskCoreException { break; case HOST_ADDRESS: break; + case CASE: + // The case object is a sibling of the data sources rather than + // their parent, so it turns up here. It is not forensic content + // and is not part of the tree. This case is not optional: without + // it the default below throws once the enum knows the value. + break; case UNSUPPORTED: break; default: @@ -15447,8 +15707,12 @@ public static final class CaseDbTransaction { private List accountsAdded = new ArrayList<>(); private List accountsMerged = new ArrayList<>(); + private List notesAdded = new ArrayList<>(); + private List notesUpdated = new ArrayList<>(); + private List deletedOsAccountObjectIds = new ArrayList<>(); private List deletedResultObjectIds = new ArrayList<>(); + private List deletedNoteIds = new ArrayList<>(); // Keep track of which threads have connections to debug deadlocks @@ -15595,6 +15859,41 @@ void registerDeletedAnalysisResult(long analysisResultObjId) { this.deletedResultObjectIds.add(analysisResultObjId); } + /** + * Saves notes that have been added as a part of this transaction. A + * batch of notes written together fires one event, so they are added to + * the same list rather than one call per note. + * + * @param notes The notes. + */ + void registerAddedNotes(List notes) { + if (notes != null) { + this.notesAdded.addAll(notes); + } + } + + /** + * Saves a note that has been revised as a part of this transaction. + * + * @param note The new current revision. + */ + void registerUpdatedNote(Note note) { + if (note != null) { + this.notesUpdated.add(note); + } + } + + /** + * Saves notes that have been deleted as a part of this transaction. + * + * @param noteIds The note ids. + */ + void registerDeletedNotes(List noteIds) { + if (noteIds != null) { + this.deletedNoteIds.addAll(noteIds); + } + } + /** * Check if the given thread has an open transaction. * @@ -15653,6 +15952,15 @@ public void commit() throws TskCoreException { if (!deletedResultObjectIds.isEmpty()) { sleuthkitCase.fireTSKEvent(new TskEvent.AnalysisResultsDeletedTskEvent(deletedResultObjectIds)); } + if (!notesAdded.isEmpty()) { + sleuthkitCase.fireTSKEvent(new TskEvent.NotesAddedTskEvent(notesAdded)); + } + if (!notesUpdated.isEmpty()) { + sleuthkitCase.fireTSKEvent(new TskEvent.NotesUpdatedTskEvent(notesUpdated)); + } + if (!deletedNoteIds.isEmpty()) { + sleuthkitCase.fireTSKEvent(new TskEvent.NotesDeletedTskEvent(deletedNoteIds)); + } } } diff --git a/bindings/java/src/org/sleuthkit/datamodel/TskData.java b/bindings/java/src/org/sleuthkit/datamodel/TskData.java index e5ca2b39ae..f81ae752f8 100644 --- a/bindings/java/src/org/sleuthkit/datamodel/TskData.java +++ b/bindings/java/src/org/sleuthkit/datamodel/TskData.java @@ -644,6 +644,7 @@ public enum ObjectType { POOL(7, bundle.getString("TskData.ObjectType.Pool.name")), ///< Pool OS_ACCOUNT(8, bundle.getString("TskData.ObjectType.OsAccount.name")), ///< OS Account - see tsk_os_accounts for more details HOST_ADDRESS(9, bundle.getString("TskData.ObjectType.HostAddress.name")), ///< Host Address - see tsk_host_addresses for more details + CASE(10, bundle.getString("TskData.ObjectType.Case.name")), ///< The case itself, so that case level notes have something to point at. One per case database, with a null parent, and its id is recorded in tsk_db_info_extended under CASE_OBJECT_ID. UNSUPPORTED(-1, bundle.getString("TskData.ObjectType.Unsupported.name")) ///< Unsupported type ; private final short objectType; diff --git a/bindings/java/src/org/sleuthkit/datamodel/TskEvent.java b/bindings/java/src/org/sleuthkit/datamodel/TskEvent.java index 723a7bfff7..fce01a5999 100755 --- a/bindings/java/src/org/sleuthkit/datamodel/TskEvent.java +++ b/bindings/java/src/org/sleuthkit/datamodel/TskEvent.java @@ -223,6 +223,95 @@ public List getHostIds() { } + /** + * An abstract super class for note events. + */ + abstract static class NotesTskEvent extends TskObjectsEvent { + + /** + * Constructs the super class part for a note event. + * + * @param notes The notes that are the subjects of the event. + */ + NotesTskEvent(List notes) { + super(notes); + } + + /** + * Gets the notes. + * + * @return The notes. + */ + public List getNotes() { + return getDataModelObjects(); + } + + } + + /** + * An event published when one or more notes are added. A batch of notes + * written together produces one event carrying all of them, not one event + * per note. + */ + public final static class NotesAddedTskEvent extends NotesTskEvent { + + /** + * Constructs an event published when one or more notes are added. + * + * @param notes The notes. + */ + NotesAddedTskEvent(List notes) { + super(notes); + } + + } + + /** + * An event published when one or more notes are revised. The notes carried + * are the new current revisions, since revising a note appends a row rather + * than rewriting one. + */ + public final static class NotesUpdatedTskEvent extends NotesTskEvent { + + /** + * Constructs an event published when one or more notes are revised. + * + * @param notes The new current revisions. + */ + NotesUpdatedTskEvent(List notes) { + super(notes); + } + + } + + /** + * An event published when one or more notes are deleted. + */ + public final static class NotesDeletedTskEvent extends TskObjectsEvent { + + /** + * Constructs an event published when one or more notes are deleted. + * + * @param noteIds The note IDs of the notes the caller asked to delete. + * A hard delete also removes the other revisions of + * those notes and their replies, which are not listed + * here. + */ + NotesDeletedTskEvent(List noteIds) { + super(noteIds); + } + + /** + * Gets the note IDs of the deleted notes. + * + * @return The note IDs. + */ + public List getNoteIds() { + return getDataModelObjects(); + } + + } + /** * An abstract super class for OS account events. */ diff --git a/bindings/java/test/org/sleuthkit/datamodel/DataModelTestSuite.java b/bindings/java/test/org/sleuthkit/datamodel/DataModelTestSuite.java index 48590631ac..b25840f286 100644 --- a/bindings/java/test/org/sleuthkit/datamodel/DataModelTestSuite.java +++ b/bindings/java/test/org/sleuthkit/datamodel/DataModelTestSuite.java @@ -48,6 +48,7 @@ AttributeTest.class, ArtifactTest.class, OsAccountTest.class, + NoteTest.class, TimelineEventTypesTest.class, CaseDbAccessManagerBatchTest.class, BatchedArtifactTest.class, diff --git a/bindings/java/test/org/sleuthkit/datamodel/NoteTest.java b/bindings/java/test/org/sleuthkit/datamodel/NoteTest.java new file mode 100644 index 0000000000..9c25a771ec --- /dev/null +++ b/bindings/java/test/org/sleuthkit/datamodel/NoteTest.java @@ -0,0 +1,763 @@ +/* + * Sleuth Kit Data Model + * + * Copyright 2026 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.datamodel; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.AfterClass; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * + * Tests the Note apis. + * + */ +public class NoteTest { + + private static final Logger LOGGER = Logger.getLogger(NoteTest.class.getName()); + + private static final String MODULE_NAME = "NoteTest"; + + private final static String TEST_DB = "NoteApiTest.db"; + + private static SleuthkitCase caseDB; + private static String dbPath = null; + private static Image image = null; + private static FileSystem fs = null; + + private static NoteType commentType; + private static NoteType summaryType; + + private static final Note.Author ANALYST = new Note.Author(Note.AuthorKind.USER, "user-1", "Alice Analyst"); + private static final Note.Author OTHER_ANALYST = new Note.Author(Note.AuthorKind.USER, "user-2", "Bob Analyst"); + private static final Note.Author MODEL = new Note.Author(Note.AuthorKind.AI, "model-1", "Some Model", "prompt-v1"); + + public NoteTest() { + + } + + @BeforeClass + public static void setUpClass() { + String tempDirPath = System.getProperty("java.io.tmpdir"); + try { + dbPath = Paths.get(tempDirPath, TEST_DB).toString(); + + // Delete the DB file, in case + java.io.File dbFile = new java.io.File(dbPath); + dbFile.delete(); + if (dbFile.getParentFile() != null) { + dbFile.getParentFile().mkdirs(); + } + + caseDB = SleuthkitCase.newCase(dbPath); + + SleuthkitCase.CaseDbTransaction trans = caseDB.beginTransaction(); + image = caseDB.addImage(TskData.TSK_IMG_TYPE_ENUM.TSK_IMG_TYPE_DETECT, 512, 1024, "", Collections.emptyList(), "America/NewYork", null, null, null, "first", trans); + fs = caseDB.addFileSystem(image.getId(), 0, TskData.TSK_FS_TYPE_ENUM.TSK_FS_TYPE_RAW, 0, 0, 0, 0, 0, "", trans); + trans.commit(); + + commentType = caseDB.getNoteManager().getNoteType(NoteType.BuiltIn.COMMENT.getTypeName()).orElseThrow(() + -> new TskCoreException("COMMENT note type was not seeded")); + summaryType = caseDB.getNoteManager().getNoteType(NoteType.BuiltIn.AI_SUMMARY.getTypeName()).orElseThrow(() + -> new TskCoreException("AI_SUMMARY note type was not seeded")); + + System.out.println("Note Test DB created at: " + dbPath); + } catch (TskCoreException ex) { + LOGGER.log(Level.SEVERE, "Failed to create new case", ex); + } + } + + @AfterClass + public static void tearDownClass() { + + } + + /** + * The built-in types are seeded on every open, and a consumer can add its + * own without a schema change. Adding one twice must give back the same + * row, since two clients of a PostgreSQL case can do it at once. + */ + @Test + public void noteTypeTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + + for (NoteType.BuiltIn builtIn : NoteType.BuiltIn.values()) { + Optional seeded = noteManager.getNoteType(builtIn.getTypeName()); + assertTrue("Built-in note type " + builtIn.getTypeName() + " was not seeded", seeded.isPresent()); + assertEquals(builtIn.getDisplayName(), seeded.get().getDisplayName().orElse(null)); + } + + NoteType custom = noteManager.getOrAddNoteType("NOTE_TEST_CUSTOM", "Custom"); + NoteType again = noteManager.getOrAddNoteType("NOTE_TEST_CUSTOM", "A different display name"); + assertEquals(custom.getNoteTypeId(), again.getNoteTypeId()); + assertEquals("Custom", again.getDisplayName().orElse(null)); + + assertTrue(noteManager.getNoteTypes().size() >= NoteType.BuiltIn.values().length + 1); + } + + /** + * A note on a file records what the caller gave it, and the manager fills + * in the columns the caller does not supply: the data source, and the self + * references that make it its own thread root and its own first version. + */ + @Test + public void addNoteTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("addNote.txt"); + + Note note = noteManager.addNote(new NoteRequest(file.getId(), commentType, + "Looks like a dropper", "{\"priority\":\"HIGH\"}", ANALYST, null, null, null)); + + assertEquals(file.getId(), note.getObjectId()); + assertEquals("Looks like a dropper", note.getBody()); + assertEquals("{\"priority\":\"HIGH\"}", note.getDetails().orElse(null)); + assertEquals(ANALYST.getId(), note.getAuthor().getId()); + assertEquals(Note.AuthorKind.USER, note.getAuthor().getKind()); + assertFalse(note.getAuthor().getConfigId().isPresent()); + assertTrue(note.isCurrent()); + assertFalse(note.isDeleted()); + + // Derived by the manager, not passed in. + assertEquals(Long.valueOf(image.getId()), note.getDataSourceObjectId().orElse(null)); + assertEquals(note.getNoteId(), note.getRootNoteId()); + assertEquals(note.getNoteId(), note.getOriginalNoteId()); + + // What was written is what comes back. + List read = noteManager.getNotes(file.getId()); + assertEquals(1, read.size()); + assertNoteEquals(note, read.get(0)); + + // An AI note carries the prompt version, so a bad answer can be told from an old one. + Note aiNote = noteManager.addNote(new NoteRequest(file.getId(), summaryType, "Nothing notable", MODEL)); + assertEquals(Note.AuthorKind.AI, aiNote.getAuthor().getKind()); + assertEquals("prompt-v1", aiNote.getAuthor().getConfigId().orElse(null)); + + assertEquals(2, noteManager.getNotes(file.getId()).size()); + assertEquals(1, noteManager.getNotes(file.getId(), commentType).size()); + } + + /** + * A reply joins its parent's thread and reads back with it in one query. A + * reply on a different object is rejected, so a thread cannot span objects. + */ + @Test + public void threadTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("thread.txt"); + AbstractFile otherFile = addFile("threadOther.txt"); + + Note root = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Is this expected?", ANALYST)); + Note reply = noteManager.addNote(new NoteRequest(file.getId(), commentType, "No, it is not", + null, OTHER_ANALYST, root.getNoteId(), null, null)); + Note replyToReply = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Agreed", + null, ANALYST, reply.getNoteId(), null, null)); + + assertEquals(root.getNoteId(), reply.getRootNoteId()); + assertEquals(root.getNoteId(), replyToReply.getRootNoteId()); + assertEquals(Long.valueOf(reply.getNoteId()), replyToReply.getParentNoteId().orElse(null)); + + // A reply is its own first version, even though it is not its own root. + assertEquals(reply.getNoteId(), reply.getOriginalNoteId()); + + List thread = noteManager.getThread(root.getNoteId()); + assertEquals(3, thread.size()); + assertEquals(root.getNoteId(), thread.get(0).getNoteId()); + + try { + noteManager.addNote(new NoteRequest(otherFile.getId(), commentType, "Wrong object", + null, ANALYST, root.getNoteId(), null, null)); + fail("Expected a reply on a different object to be rejected"); + } catch (TskCoreException ex) { + assertTrue(ex.getMessage().contains("cannot span objects")); + } + } + + /** + * Revising appends. The previous text keeps its row and stops being + * current, the new row joins the same lineage, and anything holding the + * original id still resolves to the live text. + */ + @Test + public void reviseNoteTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("revise.txt"); + + Note first = noteManager.addNote(new NoteRequest(file.getId(), commentType, "First draft", ANALYST)); + Note second = noteManager.reviseNote(first.getNoteId(), "Second draft", "{\"v\":2}", ANALYST); + + assertNotEquals(first.getNoteId(), second.getNoteId()); + assertEquals(first.getOriginalNoteId(), second.getOriginalNoteId()); + assertEquals(first.getRootNoteId(), second.getRootNoteId()); + assertTrue(second.isCurrent()); + + Optional current = noteManager.getCurrentRevision(first.getOriginalNoteId()); + assertTrue(current.isPresent()); + assertEquals(second.getNoteId(), current.get().getNoteId()); + assertEquals("Second draft", current.get().getBody()); + + // The broad read hands back both drafts; the narrow one hands back the live text. + List revisions = noteManager.getRevisions(first.getOriginalNoteId()); + assertEquals(2, revisions.size()); + assertEquals(first.getNoteId(), revisions.get(0).getNoteId()); + assertFalse(revisions.get(0).isCurrent()); + + List currentNotes = noteManager.getCurrentNotes(file.getId(), commentType); + assertEquals(1, currentNotes.size()); + assertEquals("Second draft", currentNotes.get(0).getBody()); + + // A model revising its own summary is the same author with a newer prompt. + Note summary = noteManager.addNote(new NoteRequest(file.getId(), summaryType, "Nothing yet", MODEL)); + Note regenerated = noteManager.reviseNote(summary.getNoteId(), "One notable item", null, + new Note.Author(Note.AuthorKind.AI, MODEL.getId(), MODEL.getDisplayName(), "prompt-v2")); + assertEquals("prompt-v2", regenerated.getAuthor().getConfigId().orElse(null)); + + // A superseded revision is not the one to revise. + try { + noteManager.reviseNote(first.getNoteId(), "Too late", null, ANALYST); + fail("Expected revising a superseded revision to be rejected"); + } catch (TskCoreException ex) { + assertTrue(ex.getMessage().contains("superseded")); + } + } + + /** + * You revise your own note and reply to someone else's. There is no path + * that rewrites another person's words under their name, so every revision + * in a lineage has one author. + */ + @Test + public void reviseRejectsADifferentAuthorTest() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("author.txt"); + + Note note = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Mine", ANALYST)); + try { + noteManager.reviseNote(note.getNoteId(), "Not yours to edit", null, OTHER_ANALYST); + fail("Expected revising another author's note to be rejected"); + } catch (TskCoreException ex) { + assertTrue(ex.getMessage().contains("different author")); + } + + // Nothing was written. + assertEquals(1, noteManager.getRevisions(note.getOriginalNoteId()).size()); + assertEquals("Mine", noteManager.getCurrentRevision(note.getOriginalNoteId()).get().getBody()); + } + + /** + * The revision flip is a check-then-act with no lock behind it on + * PostgreSQL, so it is settled by a constraint instead. Two current + * revisions of one note have to be impossible rather than merely unlikely. + */ + @Test + public void uniqueIndexRejectsASecondCurrentRevisionTest() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("concurrent.txt"); + + Note first = noteManager.addNote(new NoteRequest(file.getId(), commentType, "First", ANALYST)); + noteManager.reviseNote(first.getNoteId(), "Second", null, ANALYST); + + // Stand in for a second writer that flipped is_current without noticing the + // first one had already done it. + try (SleuthkitCase.CaseDbConnection connection = caseDB.getConnection(); + Statement s = connection.createStatement()) { + + s.executeUpdate("UPDATE tsk_notes SET is_current = 1 WHERE note_id = " + first.getNoteId()); + fail("Expected the unique index to reject a second current revision"); + } catch (SQLException ex) { + // Expected. + } + + assertEquals(1, noteManager.getCurrentNotes(file.getId(), commentType).size()); + } + + /** + * A note written through addNotes() must be indistinguishable from one + * written through addNote() on the columns the manager derives. That is the + * whole reason the self references are back-filled rather than + * pre-allocated. + */ + @Test + public void batchAndSingleParityTest() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("parity.txt"); + + Note single = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Written one at a time", ANALYST)); + + SleuthkitCase.CaseDbTransaction trans = caseDB.beginTransaction(); + List batch; + try { + batch = noteManager.addNotes(Arrays.asList( + new NoteRequest(file.getId(), commentType, "Written in a batch", ANALYST), + new NoteRequest(file.getId(), commentType, "Also in the batch", ANALYST)), trans); + trans.commit(); + trans = null; + } finally { + if (trans != null) { + trans.rollback(); + } + } + + assertEquals(2, batch.size()); + assertEquals("Written in a batch", batch.get(0).getBody()); + assertEquals("Also in the batch", batch.get(1).getBody()); + + for (Note note : batch) { + assertEquals("Batch root note should be its own thread root", note.getNoteId(), note.getRootNoteId()); + assertEquals("Batch root note should be its own first version", note.getNoteId(), note.getOriginalNoteId()); + assertEquals(single.getDataSourceObjectId(), note.getDataSourceObjectId()); + } + + // The returned objects have to match what was persisted, since callers use them + // without reading back. + for (Note note : batch) { + assertNoteEquals(note, noteManager.getNoteById(note.getNoteId()).get()); + } + + // A reply written in a batch inherits its parent's thread the same way a reply + // written on its own does. + trans = caseDB.beginTransaction(); + List replies; + try { + replies = noteManager.addNotes(Collections.singletonList( + new NoteRequest(file.getId(), commentType, "Batched reply", null, ANALYST, single.getNoteId(), null, null)), trans); + trans.commit(); + trans = null; + } finally { + if (trans != null) { + trans.rollback(); + } + } + assertEquals(single.getNoteId(), replies.get(0).getRootNoteId()); + assertEquals(replies.get(0).getNoteId(), replies.get(0).getOriginalNoteId()); + } + + /** + * The multi-row INSERT that PostgreSQL takes is the same SQL on either + * engine, so run it here and check that it writes the same rows, in the + * same order, as inserting one at a time. Without this the batched path + * would have no coverage at all in a SQLite-only test run. + */ + @Test + public void batchedInsertPathTest() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("batchedPath.txt"); + + List requests = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + requests.add(new NoteRequest(file.getId(), commentType, "Note " + i, + "{\"i\":" + i + "}", ANALYST, null, null, 1700000000000L + i)); + } + + SleuthkitCase.CaseDbTransaction trans = caseDB.beginTransaction(); + List batched; + try { + batched = noteManager.addNotes(requests, trans, true); + trans.commit(); + trans = null; + } finally { + if (trans != null) { + trans.rollback(); + } + } + + assertEquals(5, batched.size()); + for (int i = 0; i < 5; i++) { + Note note = batched.get(i); + assertEquals("Batched notes must come back in request order", "Note " + i, note.getBody()); + assertEquals("{\"i\":" + i + "}", note.getDetails().orElse(null)); + assertEquals(1700000000000L + i, note.getCreatedTime()); + assertEquals(note.getNoteId(), note.getRootNoteId()); + assertEquals(note.getNoteId(), note.getOriginalNoteId()); + assertNoteEquals(note, noteManager.getNoteById(note.getNoteId()).get()); + } + + // A reply written through the batched path picks up its parent's thread just + // as it does through the row at a time path. + trans = caseDB.beginTransaction(); + try { + List reply = noteManager.addNotes(Collections.singletonList( + new NoteRequest(file.getId(), commentType, "Batched reply", null, ANALYST, batched.get(0).getNoteId(), null, null)), + trans, true); + trans.commit(); + trans = null; + assertEquals(batched.get(0).getNoteId(), reply.get(0).getRootNoteId()); + assertEquals(reply.get(0).getNoteId(), reply.get(0).getOriginalNoteId()); + } finally { + if (trans != null) { + trans.rollback(); + } + } + } + + /** + * A hard delete takes the thread underneath the note, and every revision of + * it. A soft delete keeps the row so that a colleague's reply is not lost + * because someone retracted the note it hangs from. + */ + @Test + public void deleteNoteTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("delete.txt"); + + Note root = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Retract me", ANALYST)); + Note reply = noteManager.addNote(new NoteRequest(file.getId(), commentType, "Replying", + null, OTHER_ANALYST, root.getNoteId(), null, null)); + + noteManager.deleteNote(root.getNoteId(), NoteManager.DeleteMode.SOFT); + List afterSoftDelete = noteManager.getThread(root.getNoteId()); + assertEquals(2, afterSoftDelete.size()); + assertTrue(afterSoftDelete.get(0).isDeleted()); + assertFalse("A soft delete must not touch the replies", afterSoftDelete.get(1).isDeleted()); + + // A retraction stands. Revising the note would otherwise write a row that takes + // the is_deleted default and quietly bring it back. + try { + noteManager.reviseNote(root.getNoteId(), "Actually, let me rephrase", null, ANALYST); + fail("Expected revising a deleted note to be rejected"); + } catch (TskCoreException ex) { + assertTrue(ex.getMessage().contains("has been deleted")); + } + assertTrue(noteManager.getCurrentRevision(root.getOriginalNoteId()).get().isDeleted()); + + // Revise the reply first: the cascade has to take a reply that is several rows, + // whose later revisions reference its first one. + Note revisedReply = noteManager.reviseNote(reply.getNoteId(), "Replying, more carefully", null, OTHER_ANALYST); + noteManager.addNote(new NoteRequest(file.getId(), commentType, "Reply to the reply", + null, ANALYST, revisedReply.getNoteId(), null, null)); + + noteManager.deleteNote(root.getNoteId(), NoteManager.DeleteMode.HARD); + assertTrue(noteManager.getThread(root.getNoteId()).isEmpty()); + assertFalse(noteManager.getNoteById(reply.getNoteId()).isPresent()); + assertTrue(noteManager.getRevisions(reply.getOriginalNoteId()).isEmpty()); + + // A revised note is more than one row, and a hard delete has to take all of + // them: the later revisions reference the first. + Note revised = noteManager.addNote(new NoteRequest(file.getId(), commentType, "First", ANALYST)); + noteManager.reviseNote(revised.getNoteId(), "Second", null, ANALYST); + noteManager.deleteNote(revised.getNoteId(), NoteManager.DeleteMode.HARD); + assertTrue(noteManager.getRevisions(revised.getOriginalNoteId()).isEmpty()); + + assertTrue(noteManager.getNotes(file.getId()).isEmpty()); + } + + /** + * Both delete modes work on the whole revision lineage, so a consumer + * holding the stable original note id - which is what an analysis result's + * TSK_NOTE_ID attribute carries - retracts the note the reader can see, not + * just the draft that id happens to name. + */ + @Test + public void deleteByOriginalNoteIdTest() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("deleteByOriginal.txt"); + + Note first = noteManager.addNote(new NoteRequest(file.getId(), commentType, "First", ANALYST)); + Note second = noteManager.reviseNote(first.getNoteId(), "Second", null, ANALYST); + assertNotEquals("The stable id names the superseded draft after a revision", + first.getOriginalNoteId(), second.getNoteId()); + + noteManager.deleteNote(first.getOriginalNoteId(), NoteManager.DeleteMode.SOFT); + + for (Note revision : noteManager.getRevisions(first.getOriginalNoteId())) { + assertTrue("Every revision in the lineage should be marked deleted", revision.isDeleted()); + } + assertTrue(noteManager.getCurrentRevision(first.getOriginalNoteId()).get().isDeleted()); + + noteManager.deleteNote(first.getOriginalNoteId(), NoteManager.DeleteMode.HARD); + assertTrue(noteManager.getRevisions(first.getOriginalNoteId()).isEmpty()); + } + + /** + * A table of items needs a note count per row without loading any prose. + * The broad count includes every revision, matching the broad reads; the + * current count is the one a badge wants. + */ + @Test + public void batchReadTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile fileOne = addFile("batchReadOne.txt"); + AbstractFile fileTwo = addFile("batchReadTwo.txt"); + AbstractFile fileThree = addFile("batchReadThree.txt"); + + Note revised = noteManager.addNote(new NoteRequest(fileOne.getId(), commentType, "First", ANALYST)); + noteManager.reviseNote(revised.getNoteId(), "Second", null, ANALYST); + noteManager.addNote(new NoteRequest(fileTwo.getId(), commentType, "Only one", ANALYST)); + noteManager.addNote(new NoteRequest(fileTwo.getId(), summaryType, "A summary", MODEL)); + + List objIds = Arrays.asList(fileOne.getId(), fileTwo.getId(), fileThree.getId()); + + Map> comments = noteManager.getNotes(objIds, commentType); + assertEquals(2, comments.get(fileOne.getId()).size()); + assertEquals(1, comments.get(fileTwo.getId()).size()); + assertFalse("Objects with no note are absent from the map", comments.containsKey(fileThree.getId())); + + Map allCounts = noteManager.getNoteCounts(objIds, commentType); + assertEquals(Integer.valueOf(2), allCounts.get(fileOne.getId())); + assertEquals(Integer.valueOf(1), allCounts.get(fileTwo.getId())); + + Map currentCounts = noteManager.getCurrentNoteCounts(objIds, commentType); + assertEquals(Integer.valueOf(1), currentCounts.get(fileOne.getId())); + assertEquals(Integer.valueOf(1), currentCounts.get(fileTwo.getId())); + + // The badge count and the list behind it have to agree, retractions included. + // Whether a retraction is shown is the consumer's ruling, not this manager's. + noteManager.deleteNote(revised.getOriginalNoteId(), NoteManager.DeleteMode.SOFT); + assertEquals(noteManager.getCurrentNotes(fileOne.getId(), commentType).size(), + (int) noteManager.getCurrentNoteCounts(objIds, commentType).get(fileOne.getId())); + + List summaries = noteManager.getNotesForDataSource(image.getId(), summaryType); + assertTrue(summaries.stream().anyMatch(note -> note.getObjectId() == fileTwo.getId())); + } + + /** + * The reasoning behind a finding lives in the note and the score lives in + * the analysis result, linked both ways. The attribute holds the note's + * stable id rather than a revision id, so it still resolves to the live + * text after the note is edited. + */ + @Test + public void analysisResultLinkTest() throws TskCoreException, Blackboard.BlackboardException { + NoteManager noteManager = caseDB.getNoteManager(); + AbstractFile file = addFile("finding.txt"); + + AnalysisResultAdded added = file.newAnalysisResult( + new BlackboardArtifact.Type(BlackboardArtifact.ARTIFACT_TYPE.TSK_INTERESTING_FILE_HIT), + Score.SCORE_LIKELY_NOTABLE, "Suspicious executable", "", "", Collections.emptyList()); + AnalysisResult result = added.getAnalysisResult(); + + Note note = noteManager.addNote(new NoteRequest(file.getId(), summaryType, + "Signed by an unknown publisher and launched from a temp folder", + "{\"mitre\":[\"T1204\"]}", MODEL, null, result.getId(), null)); + assertEquals(Long.valueOf(result.getId()), note.getAnalysisResultId().orElse(null)); + + result.addAttribute(new BlackboardAttribute(BlackboardAttribute.Type.TSK_NOTE_ID, MODULE_NAME, note.getOriginalNoteId())); + + // Revising the note must not invalidate the attribute. + noteManager.reviseNote(note.getNoteId(), "Also seen contacting a known bad host", null, + new Note.Author(Note.AuthorKind.AI, MODEL.getId(), MODEL.getDisplayName(), "prompt-v2")); + + AnalysisResult reread = caseDB.getBlackboard().getAnalysisResultById(result.getId()); + BlackboardAttribute noteAttribute = reread.getAttribute(BlackboardAttribute.Type.TSK_NOTE_ID); + Optional resolved = noteManager.getCurrentRevision(noteAttribute.getValueLong()); + assertTrue(resolved.isPresent()); + assertEquals("Also seen contacting a known bad host", resolved.get().getBody()); + + // A note about a finding is anchored on it and has no analysis result of its + // own; a note explaining the finding is the other way round. + Note comment = noteManager.addNote(new NoteRequest(result.getId(), commentType, "I disagree with this", ANALYST)); + assertFalse(comment.getAnalysisResultId().isPresent()); + assertEquals(1, noteManager.getNotes(result.getId(), commentType).size()); + } + + /** + * A case level note hangs from the case object, which is a root level + * object with no parent and therefore no data source. + */ + @Test + public void caseObjectTests() throws TskCoreException { + NoteManager noteManager = caseDB.getNoteManager(); + + long caseObjId = caseDB.getCaseObjectId(); + assertTrue("A case object should have been created", caseObjId > 0); + + Note summary = noteManager.addNote(new NoteRequest(caseObjId, summaryType, + "Two hosts, one confirmed compromise", null, MODEL, null, null, null)); + assertFalse("A case level note has no data source", summary.getDataSourceObjectId().isPresent()); + assertEquals(1, noteManager.getCurrentNotes(caseObjId, summaryType).size()); + + // The case object is parentless, so it turns up in the root object query. That + // query throws on a root type it does not recognise. + List roots = caseDB.getRootObjects(); + assertEquals(1, roots.size()); + assertEquals(image.getId(), roots.get(0).getId()); + + assertCaseObjectRowCount(caseDB, 1); + } + + /** + * Reopening a case must find the recorded case object rather than making a + * second one, since the get-or-create runs on every open and not only at + * creation. + */ + @Test + public void caseObjectIsStableAcrossOpensTest() throws Exception { + String reopenDbPath = newDbPath("NoteApiCaseObjectTest.db"); + + SleuthkitCase reopenCase = SleuthkitCase.newCase(reopenDbPath); + long originalId; + try { + originalId = reopenCase.getCaseObjectId(); + assertTrue(originalId > 0); + } finally { + reopenCase.close(); + } + + reopenCase = SleuthkitCase.openCase(reopenDbPath); + try { + assertEquals(originalId, reopenCase.getCaseObjectId()); + assertCaseObjectRowCount(reopenCase, 1); + } finally { + reopenCase.close(); + } + } + + /** + * Opening a 9.8 case must add the note tables and the case object and leave + * a database that behaves like one created at 9.9. The 9.8 database is made + * by taking a new case back apart, which is as close as this test can get + * without a build of the older schema. + */ + @Test + public void upgradeFromSchema9dot8Test() throws Exception { + String upgradeDbPath = newDbPath("NoteApiUpgradeTest.db"); + + SleuthkitCase newCase = SleuthkitCase.newCase(upgradeDbPath); + newCase.close(); + + try (java.sql.Connection connection = java.sql.DriverManager.getConnection("jdbc:sqlite:" + upgradeDbPath); + Statement s = connection.createStatement()) { + + s.executeUpdate("DROP TABLE tsk_notes"); + s.executeUpdate("DROP TABLE tsk_note_types"); + s.executeUpdate("DELETE FROM tsk_db_info_extended WHERE name = 'CASE_OBJECT_ID'"); + s.executeUpdate("DELETE FROM tsk_objects WHERE type = " + TskData.ObjectType.CASE.getObjectType()); + s.executeUpdate("UPDATE tsk_db_info SET schema_minor_ver = 8"); + s.executeUpdate("UPDATE tsk_db_info_extended SET value = '8' WHERE name = 'SCHEMA_MINOR_VERSION'"); + } + + SleuthkitCase upgradedCase = SleuthkitCase.openCase(upgradeDbPath); + try { + try (SleuthkitCase.CaseDbConnection connection = upgradedCase.getConnection(); + Statement s = connection.createStatement(); + ResultSet rs = s.executeQuery("SELECT schema_ver, schema_minor_ver FROM tsk_db_info")) { + rs.next(); + assertEquals(9, rs.getInt("schema_ver")); + assertEquals(9, rs.getInt("schema_minor_ver")); + } catch (SQLException ex) { + throw new TskCoreException("Error reading the schema version", ex); + } + + // The upgraded case has the note tables, the seeded types and a case object, + // and writing a note through them works the same as on a new case. + assertCaseObjectRowCount(upgradedCase, 1); + NoteManager noteManager = upgradedCase.getNoteManager(); + NoteType upgradedCommentType = noteManager.getNoteType(NoteType.BuiltIn.COMMENT.getTypeName()).get(); + + Note note = noteManager.addNote(new NoteRequest(upgradedCase.getCaseObjectId(), + upgradedCommentType, "Written after the upgrade", ANALYST)); + assertEquals(note.getNoteId(), note.getRootNoteId()); + assertEquals(note.getNoteId(), note.getOriginalNoteId()); + assertFalse(note.getDataSourceObjectId().isPresent()); + + Note revision = noteManager.reviseNote(note.getNoteId(), "Revised after the upgrade", null, ANALYST); + assertEquals(note.getOriginalNoteId(), revision.getOriginalNoteId()); + assertEquals(1, noteManager.getCurrentNotes(upgradedCase.getCaseObjectId(), upgradedCommentType).size()); + } finally { + upgradedCase.close(); + } + } + + /** + * Make a path for a scratch case database, removing any file left behind by + * an earlier run. + */ + private static String newDbPath(String fileName) { + String path = Paths.get(System.getProperty("java.io.tmpdir"), fileName).toString(); + new java.io.File(path).delete(); + return path; + } + + /** + * Assert that the case has exactly the expected number of case objects, and + * that the recorded id names one of them. + */ + private static void assertCaseObjectRowCount(SleuthkitCase skCase, int expected) throws TskCoreException { + try (SleuthkitCase.CaseDbConnection connection = skCase.getConnection(); + Statement s = connection.createStatement()) { + + try (ResultSet rs = s.executeQuery("SELECT COUNT(*) AS count FROM tsk_objects WHERE type = " + + TskData.ObjectType.CASE.getObjectType() + " AND par_obj_id IS NULL")) { + rs.next(); + assertEquals(expected, rs.getInt("count")); + } + + try (ResultSet rs = s.executeQuery("SELECT value FROM tsk_db_info_extended WHERE name = 'CASE_OBJECT_ID'")) { + assertTrue("CASE_OBJECT_ID should be recorded", rs.next()); + assertEquals(skCase.getCaseObjectId(), Long.parseLong(rs.getString("value"))); + } + } catch (SQLException ex) { + throw new TskCoreException("Error counting case objects", ex); + } + } + + /** + * Assert that two notes are the same row. + */ + private static void assertNoteEquals(Note expected, Note actual) { + assertEquals(expected.getNoteId(), actual.getNoteId()); + assertEquals(expected.getObjectId(), actual.getObjectId()); + assertEquals(expected.getDataSourceObjectId(), actual.getDataSourceObjectId()); + assertEquals(expected.getType().getNoteTypeId(), actual.getType().getNoteTypeId()); + assertEquals(expected.getBody(), actual.getBody()); + assertEquals(expected.getDetails(), actual.getDetails()); + assertEquals(expected.getAuthor(), actual.getAuthor()); + assertEquals(expected.getCreatedTime(), actual.getCreatedTime()); + assertEquals(expected.getParentNoteId(), actual.getParentNoteId()); + assertEquals(expected.getRootNoteId(), actual.getRootNoteId()); + assertEquals(expected.getOriginalNoteId(), actual.getOriginalNoteId()); + assertEquals(expected.isCurrent(), actual.isCurrent()); + assertEquals(expected.isDeleted(), actual.isDeleted()); + assertEquals(expected.getAnalysisResultId(), actual.getAnalysisResultId()); + } + + /** + * Add a file to annotate. + */ + private static AbstractFile addFile(String name) throws TskCoreException { + SleuthkitCase.CaseDbTransaction trans = caseDB.beginTransaction(); + try { + FsContent file = caseDB.addFileSystemFile(image.getId(), fs.getId(), name, 0, 0, + TskData.TSK_FS_ATTR_TYPE_ENUM.TSK_FS_ATTR_TYPE_DEFAULT, 0, TskData.TSK_FS_NAME_FLAG_ENUM.ALLOC, + (short) 0, 200, 0, 0, 0, 0, null, null, null, false, fs, null, null, new ArrayList<>(), trans); + trans.commit(); + trans = null; + return file; + } finally { + if (trans != null) { + trans.rollback(); + } + } + } +}