Browse Source

Make various processors more defensive

tags/v0.10.3
Chris Smith 5 years ago
parent
commit
1b135480c9

+ 6
- 1
CHANGELOG View File

@@ -1,7 +1,12 @@
1 1
 vNEXT (in development)
2 2
 
3 3
  * Fix issue parsing CTCPs when the content contained multi-byte chars
4
- * If the server provides an invalid timestamp, the event now defaults to the current time
4
+ * Fixed multiple issues when receiving malformed input from the server:
5
+   * Invalid server-time tags are now ignored and the current time used
6
+   * PRIVMSGs, NOTICEs and TOPICs with no target/message are now ignored
7
+   * CAPs with invalid or missing arguments are now ignored
8
+   * Better handling for duplicate JOIN messages
9
+   * Ignored messages are now logged more consistently
5 10
 
6 11
 v0.10.2
7 12
 

+ 11
- 4
src/main/kotlin/com/dmdirc/ktirc/messages/processors/CapabilityProcessor.kt View File

@@ -14,10 +14,17 @@ internal class CapabilityProcessor : MessageProcessor {
14 14
 
15 15
     override val commands = arrayOf("CAP")
16 16
 
17
-    override fun process(message: IrcMessage) = when (message.subCommand) {
18
-        "LS" -> handleList(message.metadata, message.subCommandArguments)
19
-        "ACK" -> listOf(ServerCapabilitiesAcknowledged(message.metadata, message.params.capabilities))
20
-        else -> emptyList()
17
+    override fun process(message: IrcMessage) = when {
18
+        message.params.size < 2 -> {
19
+            log.warning { "Discarding CAP with insufficient args: $message" }
20
+            emptyList()
21
+        }
22
+        message.subCommand == "LS" -> handleList(message.metadata, message.subCommandArguments)
23
+        message.subCommand == "ACK" -> listOf(ServerCapabilitiesAcknowledged(message.metadata, message.params.capabilities))
24
+        else -> {
25
+            log.warning { "Discarding CAP with unknown subcommand: $message" }
26
+            emptyList()
27
+        }
21 28
     }
22 29
 
23 30
     private fun handleList(metadata: EventMetadata, lsParams: List<ByteArray>) = sequence {

+ 9
- 0
src/main/kotlin/com/dmdirc/ktirc/messages/processors/ModeProcessor.kt View File

@@ -4,14 +4,23 @@ import com.dmdirc.ktirc.events.ModeChanged
4 4
 import com.dmdirc.ktirc.messages.RPL_CHANNELMODEIS
5 5
 import com.dmdirc.ktirc.messages.RPL_UMODEIS
6 6
 import com.dmdirc.ktirc.model.IrcMessage
7
+import com.dmdirc.ktirc.util.logger
7 8
 
8 9
 internal class ModeProcessor : MessageProcessor {
9 10
 
11
+    private val log by logger()
12
+
10 13
     override val commands = arrayOf(RPL_CHANNELMODEIS, RPL_UMODEIS, "MODE")
11 14
 
12 15
     override fun process(message: IrcMessage): List<ModeChanged> {
13 16
         val isDiscovery = message.command == RPL_CHANNELMODEIS || message.command == RPL_UMODEIS
14 17
         val paramOffset = if (message.command == RPL_CHANNELMODEIS) 1 else 0
18
+
19
+        if (message.params.size < paramOffset + 2) {
20
+            log.warning { "Discarding MODE line with insufficient parameters: $message" }
21
+            return emptyList()
22
+        }
23
+
15 24
         return listOf(ModeChanged(
16 25
                 message.metadata,
17 26
                 target = String(message.params[paramOffset]),

+ 11
- 3
src/main/kotlin/com/dmdirc/ktirc/messages/processors/NoticeProcessor.kt View File

@@ -6,20 +6,28 @@ import com.dmdirc.ktirc.events.NoticeReceived
6 6
 import com.dmdirc.ktirc.messages.CTCP_BYTE
7 7
 import com.dmdirc.ktirc.model.IrcMessage
8 8
 import com.dmdirc.ktirc.model.User
9
+import com.dmdirc.ktirc.util.logger
9 10
 
10 11
 internal class NoticeProcessor : MessageProcessor {
11 12
 
13
+    private val log by logger()
14
+
12 15
     override val commands = arrayOf("NOTICE")
13 16
 
14 17
     override fun process(message: IrcMessage) = when {
15
-            message.isCtcp() -> handleCtcp(message,  message.sourceUser)
16
-            else -> listOf(NoticeReceived(message.metadata,  message.sourceUser ?: User("*"), String(message.params[0]), String(message.params[1])))
18
+        message.params.size < 2 -> {
19
+            log.warning { "Discarding NOTICE line with insufficient parameters: $message" }
20
+            emptyList()
17 21
         }
22
+        message.isCtcp() -> handleCtcp(message, message.sourceUser)
23
+        else -> listOf(NoticeReceived(message.metadata, message.sourceUser
24
+                ?: User("*"), String(message.params[0]), String(message.params[1])))
25
+    }
18 26
 
19 27
     private fun handleCtcp(message: IrcMessage, user: User?): List<IrcEvent> {
20 28
         user ?: return emptyList()
21 29
         val content = String(message.params[1].sliceArray(1 until message.params[1].size - 1))
22
-        val parts = content.split(' ', limit=2)
30
+        val parts = content.split(' ', limit = 2)
23 31
         val body = if (parts.size == 2) parts[1] else ""
24 32
         return listOf(CtcpReplyReceived(message.metadata, user, String(message.params[0]), parts[0], body))
25 33
     }

+ 13
- 7
src/main/kotlin/com/dmdirc/ktirc/messages/processors/PrivmsgProcessor.kt View File

@@ -7,21 +7,27 @@ import com.dmdirc.ktirc.events.MessageReceived
7 7
 import com.dmdirc.ktirc.messages.CTCP_BYTE
8 8
 import com.dmdirc.ktirc.model.IrcMessage
9 9
 import com.dmdirc.ktirc.model.User
10
+import com.dmdirc.ktirc.util.logger
10 11
 
11 12
 internal class PrivmsgProcessor : MessageProcessor {
12 13
 
14
+    private val log by logger()
15
+
13 16
     override val commands = arrayOf("PRIVMSG")
14 17
 
15
-    override fun process(message: IrcMessage) = message.sourceUser?.let { user ->
16
-        listOf(when {
17
-            message.isCtcp() -> handleCtcp(message, user)
18
-            else -> MessageReceived(message.metadata, user, String(message.params[0]), String(message.params[1]))
19
-        })
20
-    } ?: emptyList()
18
+    override fun process(message: IrcMessage) = when {
19
+        message.sourceUser == null -> emptyList()
20
+        message.params.size < 2 -> {
21
+            log.warning { "Discarding PRIVMSG line with insufficient parameters: $message" }
22
+            emptyList()
23
+        }
24
+        message.isCtcp() -> listOf(handleCtcp(message, message.sourceUser))
25
+        else -> listOf(MessageReceived(message.metadata, message.sourceUser, String(message.params[0]), String(message.params[1])))
26
+    }
21 27
 
22 28
     private fun handleCtcp(message: IrcMessage, user: User): IrcEvent {
23 29
         val content = String(message.params[1].sliceArray(1 until message.params[1].size - 1))
24
-        val parts = content.split(' ', limit=2)
30
+        val parts = content.split(' ', limit = 2)
25 31
         val body = if (parts.size == 2) parts[1] else ""
26 32
         return when (parts[0].toUpperCase()) {
27 33
             "ACTION" -> ActionReceived(message.metadata, user, String(message.params[0]), body)

+ 10
- 1
src/main/kotlin/com/dmdirc/ktirc/messages/processors/TopicProcessor.kt View File

@@ -9,11 +9,14 @@ import com.dmdirc.ktirc.messages.RPL_TOPICWHOTIME
9 9
 import com.dmdirc.ktirc.model.IrcMessage
10 10
 import com.dmdirc.ktirc.model.asUser
11 11
 import com.dmdirc.ktirc.util.currentTimeZoneProvider
12
+import com.dmdirc.ktirc.util.logger
12 13
 import java.time.Instant
13 14
 import java.time.LocalDateTime
14 15
 
15 16
 internal class TopicProcessor : MessageProcessor {
16 17
 
18
+    private val log by logger()
19
+
17 20
     override val commands = arrayOf(RPL_TOPIC, RPL_TOPICWHOTIME, RPL_NOTOPIC, "TOPIC")
18 21
 
19 22
     override fun process(message: IrcMessage) = sequence {
@@ -22,7 +25,13 @@ internal class TopicProcessor : MessageProcessor {
22 25
             RPL_NOTOPIC -> yield(ChannelTopicDiscovered(message.metadata, message.channel, null))
23 26
             RPL_TOPICWHOTIME -> yield(ChannelTopicMetadataDiscovered(
24 27
                     message.metadata, message.channel, message.params[2].asUser(), message.topicSetTime))
25
-            "TOPIC" -> message.sourceUser?.let { yield(ChannelTopicChanged(message.metadata, it, String(message.params[0]), String(message.params[1]))) }
28
+            "TOPIC" -> message.sourceUser?.let {
29
+                if (message.params.size >= 2) {
30
+                    yield(ChannelTopicChanged(message.metadata, it, String(message.params[0]), String(message.params[1])))
31
+                } else {
32
+                    log.warning { "Discarding TOPIC line with insufficient parameters: $message" }
33
+                }
34
+            }
26 35
         }
27 36
     }.toList()
28 37
 

+ 5
- 1
src/main/kotlin/com/dmdirc/ktirc/messages/processors/WelcomeProcessor.kt View File

@@ -8,8 +8,12 @@ internal class WelcomeProcessor : MessageProcessor {
8 8
 
9 9
     override val commands = arrayOf(RPL_WELCOME)
10 10
 
11
-    override fun process(message: IrcMessage) = listOf(ServerWelcome(message.metadata, message.serverName(), String(message.params[0])))
11
+    override fun process(message: IrcMessage) = listOf(ServerWelcome(
12
+            message.metadata,
13
+            message.serverName(),
14
+            message.localNick()))
12 15
 
13 16
     private fun IrcMessage.serverName() = prefix?.let { String(it) } ?: ""
17
+    private fun IrcMessage.localNick() = if (params.isEmpty()) "" else String(params[0])
14 18
 
15 19
 }

+ 7
- 5
src/main/kotlin/com/dmdirc/ktirc/model/IrcMessage.kt View File

@@ -23,19 +23,21 @@ internal class IrcMessage(val tags: Map<MessageTag, String>, val prefix: ByteArr
23 23
             label = tags[MessageTag.Label])
24 24
 
25 25
     /** The user that generated the message, if any. */
26
-    val sourceUser by lazy {
27
-        prefix?.asUser()?.apply {
28
-            tags[MessageTag.AccountName]?.let { account = it }
29
-        }
26
+    val sourceUser = prefix?.asUser()?.apply {
27
+        tags[MessageTag.AccountName]?.let { account = it }
30 28
     }
31 29
 
32 30
     private fun String.toLocalDateOrNull() = try {
33 31
         LocalDateTime.ofInstant(Instant.parse(this), currentTimeZoneProvider())
34
-    } catch(e: Exception) {
32
+    } catch (e: Exception) {
35 33
         log.log(Level.WARNING, e) { "Received unparsable server-time tag: $this" }
36 34
         null
37 35
     }
38 36
 
37
+    override fun toString(): String {
38
+        return "IrcMessage(tags=$tags, prefix=${prefix?.let { String(prefix) }}, command='$command', params=${params.map { String(it) }}, metadata=$metadata, sourceUser=$sourceUser)"
39
+    }
40
+
39 41
 }
40 42
 
41 43
 /**

+ 0
- 1
src/main/kotlin/com/dmdirc/ktirc/model/Maps.kt View File

@@ -14,7 +14,6 @@ abstract class CaseInsensitiveMap<T>(private val caseMappingProvider: () -> Case
14 14
     operator fun get(name: String) = synchronized(values) { values.find { caseMappingProvider().areEquivalent(nameOf(it), name) } }
15 15
 
16 16
     internal operator fun plusAssign(value: T): Unit = synchronized(values) {
17
-        require(get(nameOf(value)) == null) { "Value already registered: ${nameOf(value)}" }
18 17
         values.add(value)
19 18
     }
20 19
 

+ 11
- 0
src/test/kotlin/com/dmdirc/ktirc/events/handlers/ChannelStateHandlerTest.kt View File

@@ -47,6 +47,17 @@ internal class ChannelStateHandlerTest {
47 47
         assertTrue("zerocool" in fakeChannelState["#thegibson"]?.users!!)
48 48
     }
49 49
 
50
+    @Test
51
+    fun `ignores duplicate joiners`() {
52
+        fakeChannelState += ChannelState("#thegibson") { CaseMapping.Rfc }
53
+
54
+        handler.processEvent(ircClient, ChannelJoined(EventMetadata(TestConstants.time), User("zerocool", "dade", "root.localhost"), "#thegibson"))
55
+        handler.processEvent(ircClient, ChannelJoined(EventMetadata(TestConstants.time), User("zerocool", "dade", "root.localhost"), "#thegibson"))
56
+
57
+        assertTrue("zerocool" in fakeChannelState["#thegibson"]?.users!!)
58
+        assertEquals(1, fakeChannelState["#thegibson"]?.users?.count())
59
+    }
60
+
50 61
     @Test
51 62
     fun `clears existing users when getting a new list`() {
52 63
         val channel = ChannelState("#thegibson") { CaseMapping.Rfc }

+ 15
- 8
src/test/kotlin/com/dmdirc/ktirc/messages/processors/CapabilityProcessorTest.kt View File

@@ -14,14 +14,21 @@ internal class CapabilityProcessorTest {
14 14
     private val processor = CapabilityProcessor()
15 15
 
16 16
     @Test
17
-    fun `CapabilityProcessor does nothing for unknown subcommand`() {
17
+    fun `does nothing for unknown subcommand`() {
18 18
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "FOO")))
19 19
 
20 20
         assertTrue(events.isEmpty())
21 21
     }
22 22
 
23 23
     @Test
24
-    fun `CapabilityProcessor raises ServerCapabilitiesReceived event given no capabilities`() {
24
+    fun `does nothing for missing subcommand`() {
25
+        val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*")))
26
+
27
+        assertTrue(events.isEmpty())
28
+    }
29
+
30
+    @Test
31
+    fun `raises ServerCapabilitiesReceived event given no capabilities`() {
25 32
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "")))
26 33
 
27 34
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesReceived>()[0]
@@ -30,7 +37,7 @@ internal class CapabilityProcessorTest {
30 37
 
31 38
 
32 39
     @Test
33
-    fun `CapabilityProcessor raises ServerCapabilitiesReceived event with known capabilities`() {
40
+    fun `raises ServerCapabilitiesReceived event with known capabilities`() {
34 41
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "chghost extended-join invalid")))
35 42
 
36 43
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesReceived>()[0]
@@ -40,7 +47,7 @@ internal class CapabilityProcessorTest {
40 47
     }
41 48
 
42 49
     @Test
43
-    fun `CapabilityProcessor raises ServerCapabilitiesReceived event with values for capabilities`() {
50
+    fun `raises ServerCapabilitiesReceived event with values for capabilities`() {
44 51
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "chghost=test123 extended-join=abc=def invalid")))
45 52
 
46 53
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesReceived>()[0]
@@ -50,7 +57,7 @@ internal class CapabilityProcessorTest {
50 57
     }
51 58
 
52 59
     @Test
53
-    fun `CapabilityProcessor overwrites earlier values with later ones for identical capabilities`() {
60
+    fun `overwrites earlier values with later ones for identical capabilities`() {
54 61
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "chghost=test123 chghost chghost=456")))
55 62
 
56 63
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesReceived>()[0]
@@ -60,7 +67,7 @@ internal class CapabilityProcessorTest {
60 67
 
61 68
 
62 69
     @Test
63
-    fun `CapabilityProcessor raises ServerCapabilitiesReceived event with known capabilities for multi-line responses`() {
70
+    fun `raises ServerCapabilitiesReceived event with known capabilities for multi-line responses`() {
64 71
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "*", "chghost extended-join invalid")))
65 72
 
66 73
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesReceived>()[0]
@@ -70,7 +77,7 @@ internal class CapabilityProcessorTest {
70 77
     }
71 78
 
72 79
     @Test
73
-    fun `CapabilityProcessor raises ServerCapabilitiesFinished event for final LS responses`() {
80
+    fun `raises ServerCapabilitiesFinished event for final LS responses`() {
74 81
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "LS", "chghost extended-join invalid")))
75 82
 
76 83
         assertEquals(2, events.size)
@@ -78,7 +85,7 @@ internal class CapabilityProcessorTest {
78 85
     }
79 86
 
80 87
     @Test
81
-    fun `CapabilityProcessor raises ServerCapabilitiesAcknowledged event`() {
88
+    fun `raises ServerCapabilitiesAcknowledged event`() {
82 89
         val events = processor.process(IrcMessage(emptyMap(), "the.gibson".toByteArray(), "CAP", params("*", "ACK", "chghost=test123 extended-join=abc=def invalid")))
83 90
 
84 91
         val receivedEvent = events.filterIsInstance<ServerCapabilitiesAcknowledged>()[0]

+ 7
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/processors/ModeProcessorTest.kt View File

@@ -127,4 +127,11 @@ internal class ModeProcessorTest {
127 127
         assertFalse(events[0].discovered)
128 128
     }
129 129
 
130
+    @Test
131
+    fun `ignores mode changes with a missing target`() {
132
+        val events = ModeProcessor().process(
133
+                IrcMessage(emptyMap(), "acidburn!libby@root.localhost".toByteArray(), "MODE", params("+hax")))
134
+        assertEquals(0, events.size)
135
+    }
136
+
130 137
 }

+ 7
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/processors/NoticeProcessorTest.kt View File

@@ -92,4 +92,11 @@ internal class NoticeProcessorTest {
92 92
                 IrcMessage(emptyMap(), null, "NOTICE", params("#crashandburn", "\u0001BORK\u0001")))
93 93
         assertEquals(0, events.size)
94 94
     }
95
+
96
+    @Test
97
+    fun `does nothing if target missing for CTCP replies`() {
98
+        val events = NoticeProcessor().process(
99
+                IrcMessage(emptyMap(), "acidburn!libby@root.localhost".toByteArray(), "NOTICE", params("\u0001BORK\u0001")))
100
+        assertEquals(0, events.size)
101
+    }
95 102
 }

+ 7
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/processors/PrivmsgProcessorTest.kt View File

@@ -119,4 +119,11 @@ internal class PrivmsgProcessorTest {
119 119
                 IrcMessage(emptyMap(), null, "PRIVMSG", params("#crashandburn", "hack the planet!")))
120 120
         assertEquals(0, events.size)
121 121
     }
122
+
123
+    @Test
124
+    fun `does nothing if arguments missing`() {
125
+        val events = PrivmsgProcessor().process(
126
+                IrcMessage(emptyMap(), "acidburn!libby@root.localhost".toByteArray(), "PRIVMSG", params("hack the planet!")))
127
+        assertEquals(0, events.size)
128
+    }
122 129
 }

+ 6
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/processors/TopicProcessorTest.kt View File

@@ -75,6 +75,12 @@ internal class TopicProcessorTest {
75 75
         assertEquals(0, events.size)
76 76
     }
77 77
 
78
+    @Test
79
+    fun `does nothing when topic is changed with no channel`() {
80
+        val events = processor.process(IrcMessage(emptyMap(), "acidBurn!acidB@the.gibson".toByteArray(), "TOPIC", params("Hack the planet!")))
81
+        assertEquals(0, events.size)
82
+    }
83
+
78 84
     private fun unixtime() = TestConstants.otherTime.toEpochSecond(currentTimeZoneProvider().rules.getOffset(TestConstants.time)).toString()
79 85
 
80 86
 }

+ 20
- 1
src/test/kotlin/com/dmdirc/ktirc/messages/processors/WelcomeProcessorTest.kt View File

@@ -18,7 +18,7 @@ internal class WelcomeProcessorTest {
18 18
     }
19 19
 
20 20
     @Test
21
-    fun `WelcomeProcessor returns server welcome event`() {
21
+    fun `returns server welcome event`() {
22 22
         val events = processor.process(IrcMessage(emptyMap(), "thegibson.com".toByteArray(), "001", params(
23 23
                 "acidBurn", "Welcome to the Internet Relay Network, acidBurn!burn@hacktheplanet.com")))
24 24
         assertEquals(1, events.size)
@@ -27,4 +27,23 @@ internal class WelcomeProcessorTest {
27 27
         assertEquals("thegibson.com", events[0].server)
28 28
     }
29 29
 
30
+    @Test
31
+    fun `returns blank server if no prefix is specified`() {
32
+        val events = processor.process(IrcMessage(emptyMap(), null, "001", params(
33
+                "acidBurn", "Welcome to the Internet Relay Network, acidBurn!burn@hacktheplanet.com")))
34
+        assertEquals(1, events.size)
35
+        assertEquals(TestConstants.time, events[0].metadata.time)
36
+        assertEquals("acidBurn", events[0].localNick)
37
+        assertEquals("", events[0].server)
38
+    }
39
+
40
+    @Test
41
+    fun `returns blank local nickname if no parameters provided`() {
42
+        val events = processor.process(IrcMessage(emptyMap(), "thegibson.com".toByteArray(), "001", params()))
43
+        assertEquals(1, events.size)
44
+        assertEquals(TestConstants.time, events[0].metadata.time)
45
+        assertEquals("", events[0].localNick)
46
+        assertEquals("thegibson.com", events[0].server)
47
+    }
48
+
30 49
 }

+ 6
- 7
src/test/kotlin/com/dmdirc/ktirc/model/MapsTest.kt View File

@@ -3,7 +3,6 @@ package com.dmdirc.ktirc.model
3 3
 import com.dmdirc.ktirc.io.CaseMapping
4 4
 import org.junit.jupiter.api.Assertions.*
5 5
 import org.junit.jupiter.api.Test
6
-import org.junit.jupiter.api.assertThrows
7 6
 
8 7
 internal class CaseInsensitiveMapTest {
9 8
 
@@ -13,7 +12,7 @@ internal class CaseInsensitiveMapTest {
13 12
     private val map = object : CaseInsensitiveMap<Wrapper>({ caseMapping }, { it -> it.name }) {}
14 13
 
15 14
     @Test
16
-    fun `CaseInsensitiveMap stores values`() {
15
+    fun `stores values`() {
17 16
         val value = Wrapper("acidBurn")
18 17
 
19 18
         map += value
@@ -22,18 +21,18 @@ internal class CaseInsensitiveMapTest {
22 21
     }
23 22
 
24 23
     @Test
25
-    fun `CaseInsensitiveMap disallows the same value twice`() {
24
+    fun `ignores the same value twice`() {
26 25
         val value = Wrapper("acidBurn")
27 26
 
27
+        map += value
28 28
         map += value
29 29
 
30
-        assertThrows<IllegalArgumentException> {
31
-            map += value
32
-        }
30
+        assertSame(value, map["acidBurn"])
31
+        assertEquals(1, map.count())
33 32
     }
34 33
 
35 34
     @Test
36
-    fun `CaseInsensitiveMap retrieves values using differently cased keys`() {
35
+    fun `retrieves values using differently cased keys`() {
37 36
         val value = Wrapper("[acidBurn]")
38 37
         map += value
39 38
 

Loading…
Cancel
Save