1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 #include "CacheIndex.h"
6 #include "CacheLog.h"
7 #include "CacheFileUtils.h"
8 #include "CacheObserver.h"
9 #include "LoadContextInfo.h"
10 #include "mozilla/Tokenizer.h"
11 #include "mozilla/Telemetry.h"
12 #include "nsCOMPtr.h"
13 #include "nsString.h"
14 #include <algorithm>
15 #include "mozilla/Unused.h"
16
17 namespace mozilla::net::CacheFileUtils {
18
19 // This designates the format for the "alt-data" metadata.
20 // When the format changes we need to update the version.
21 static uint32_t const kAltDataVersion = 1;
22 const char* kAltDataKey = "alt-data";
23
24 namespace {
25
26 /**
27 * A simple recursive descent parser for the mapping key.
28 */
29 class KeyParser : protected Tokenizer {
30 public:
KeyParser(nsACString const & aInput)31 explicit KeyParser(nsACString const& aInput)
32 : Tokenizer(aInput),
33 isAnonymous(false)
34 // Initialize the cache key to a zero length by default
35 ,
36 lastTag(0) {}
37
38 private:
39 // Results
40 OriginAttributes originAttribs;
41 bool isAnonymous;
42 nsCString idEnhance;
43 nsDependentCSubstring cacheKey;
44
45 // Keeps the last tag name, used for alphabetical sort checking
46 char lastTag;
47
48 // Classifier for the 'tag' character valid range.
49 // Explicitly using unsigned char as 127 is -1 when signed and it would only
50 // produce a warning.
TagChar(const char aChar)51 static bool TagChar(const char aChar) {
52 unsigned char c = static_cast<unsigned char>(aChar);
53 return c >= ' ' && c <= '\x7f';
54 }
55
ParseTags()56 bool ParseTags() {
57 // Expects to be at the tag name or at the end
58 if (CheckEOF()) {
59 return true;
60 }
61
62 char tag;
63 if (!ReadChar(&TagChar, &tag)) {
64 return false;
65 }
66
67 // Check the alphabetical order, hard-fail on disobedience
68 if (!(lastTag < tag || tag == ':')) {
69 return false;
70 }
71 lastTag = tag;
72
73 switch (tag) {
74 case ':':
75 // last possible tag, when present there is the cacheKey following,
76 // not terminated with ',' and no need to unescape.
77 cacheKey.Rebind(mCursor, mEnd - mCursor);
78 return true;
79 case 'O': {
80 nsAutoCString originSuffix;
81 if (!ParseValue(&originSuffix) ||
82 !originAttribs.PopulateFromSuffix(originSuffix)) {
83 return false;
84 }
85 break;
86 }
87 case 'p':
88 originAttribs.SyncAttributesWithPrivateBrowsing(true);
89 break;
90 case 'b':
91 // Leaving to be able to read and understand oldformatted entries
92 originAttribs.mInIsolatedMozBrowser = true;
93 break;
94 case 'a':
95 isAnonymous = true;
96 break;
97 case 'i': {
98 // Leaving to be able to read and understand oldformatted entries
99 uint32_t deprecatedAppId = 0;
100 if (!ReadInteger(&deprecatedAppId)) {
101 return false; // not a valid 32-bit integer
102 }
103 break;
104 }
105 case '~':
106 if (!ParseValue(&idEnhance)) {
107 return false;
108 }
109 break;
110 default:
111 if (!ParseValue()) { // skip any tag values, optional
112 return false;
113 }
114 break;
115 }
116
117 // We expect a comma after every tag
118 if (!CheckChar(',')) {
119 return false;
120 }
121
122 // Recurse to the next tag
123 return ParseTags();
124 }
125
ParseValue(nsACString * result=nullptr)126 bool ParseValue(nsACString* result = nullptr) {
127 // If at the end, fail since we expect a comma ; value may be empty tho
128 if (CheckEOF()) {
129 return false;
130 }
131
132 Token t;
133 while (Next(t)) {
134 if (!Token::Char(',').Equals(t)) {
135 if (result) {
136 result->Append(t.Fragment());
137 }
138 continue;
139 }
140
141 if (CheckChar(',')) {
142 // Two commas in a row, escaping
143 if (result) {
144 result->Append(',');
145 }
146 continue;
147 }
148
149 // We must give the comma back since the upper calls expect it
150 Rollback();
151 return true;
152 }
153
154 return false;
155 }
156
157 public:
Parse()158 already_AddRefed<LoadContextInfo> Parse() {
159 RefPtr<LoadContextInfo> info;
160 if (ParseTags()) {
161 info = GetLoadContextInfo(isAnonymous, originAttribs);
162 }
163
164 return info.forget();
165 }
166
URISpec(nsACString & result)167 void URISpec(nsACString& result) { result.Assign(cacheKey); }
168
IdEnhance(nsACString & result)169 void IdEnhance(nsACString& result) { result.Assign(idEnhance); }
170 };
171
172 } // namespace
173
ParseKey(const nsACString & aKey,nsACString * aIdEnhance,nsACString * aURISpec)174 already_AddRefed<nsILoadContextInfo> ParseKey(const nsACString& aKey,
175 nsACString* aIdEnhance,
176 nsACString* aURISpec) {
177 KeyParser parser(aKey);
178 RefPtr<LoadContextInfo> info = parser.Parse();
179
180 if (info) {
181 if (aIdEnhance) parser.IdEnhance(*aIdEnhance);
182 if (aURISpec) parser.URISpec(*aURISpec);
183 }
184
185 return info.forget();
186 }
187
AppendKeyPrefix(nsILoadContextInfo * aInfo,nsACString & _retval)188 void AppendKeyPrefix(nsILoadContextInfo* aInfo, nsACString& _retval) {
189 /**
190 * This key is used to salt file hashes. When form of the key is changed
191 * cache entries will fail to find on disk.
192 *
193 * IMPORTANT NOTE:
194 * Keep the attributes list sorted according their ASCII code.
195 */
196
197 if (!aInfo) {
198 return;
199 }
200
201 OriginAttributes const* oa = aInfo->OriginAttributesPtr();
202 nsAutoCString suffix;
203 oa->CreateSuffix(suffix);
204 if (!suffix.IsEmpty()) {
205 AppendTagWithValue(_retval, 'O', suffix);
206 }
207
208 if (aInfo->IsAnonymous()) {
209 _retval.AppendLiteral("a,");
210 }
211
212 if (aInfo->IsPrivate()) {
213 _retval.AppendLiteral("p,");
214 }
215 }
216
AppendTagWithValue(nsACString & aTarget,char const aTag,const nsACString & aValue)217 void AppendTagWithValue(nsACString& aTarget, char const aTag,
218 const nsACString& aValue) {
219 aTarget.Append(aTag);
220
221 // First check the value string to save some memory copying
222 // for cases we don't need to escape at all (most likely).
223 if (!aValue.IsEmpty()) {
224 if (!aValue.Contains(',')) {
225 // No need to escape
226 aTarget.Append(aValue);
227 } else {
228 nsAutoCString escapedValue(aValue);
229 escapedValue.ReplaceSubstring(","_ns, ",,"_ns);
230 aTarget.Append(escapedValue);
231 }
232 }
233
234 aTarget.Append(',');
235 }
236
KeyMatchesLoadContextInfo(const nsACString & aKey,nsILoadContextInfo * aInfo,bool * _retval)237 nsresult KeyMatchesLoadContextInfo(const nsACString& aKey,
238 nsILoadContextInfo* aInfo, bool* _retval) {
239 nsCOMPtr<nsILoadContextInfo> info = ParseKey(aKey);
240
241 if (!info) {
242 return NS_ERROR_FAILURE;
243 }
244
245 *_retval = info->Equals(aInfo);
246 return NS_OK;
247 }
248
ValidityPair(uint32_t aOffset,uint32_t aLen)249 ValidityPair::ValidityPair(uint32_t aOffset, uint32_t aLen)
250 : mOffset(aOffset), mLen(aLen) {}
251
CanBeMerged(const ValidityPair & aOther) const252 bool ValidityPair::CanBeMerged(const ValidityPair& aOther) const {
253 // The pairs can be merged into a single one if the start of one of the pairs
254 // is placed anywhere in the validity interval of other pair or exactly after
255 // its end.
256 return IsInOrFollows(aOther.mOffset) || aOther.IsInOrFollows(mOffset);
257 }
258
IsInOrFollows(uint32_t aOffset) const259 bool ValidityPair::IsInOrFollows(uint32_t aOffset) const {
260 return mOffset <= aOffset && mOffset + mLen >= aOffset;
261 }
262
LessThan(const ValidityPair & aOther) const263 bool ValidityPair::LessThan(const ValidityPair& aOther) const {
264 if (mOffset < aOther.mOffset) {
265 return true;
266 }
267
268 if (mOffset == aOther.mOffset && mLen < aOther.mLen) {
269 return true;
270 }
271
272 return false;
273 }
274
Merge(const ValidityPair & aOther)275 void ValidityPair::Merge(const ValidityPair& aOther) {
276 MOZ_ASSERT(CanBeMerged(aOther));
277
278 uint32_t offset = std::min(mOffset, aOther.mOffset);
279 uint32_t end = std::max(mOffset + mLen, aOther.mOffset + aOther.mLen);
280
281 mOffset = offset;
282 mLen = end - offset;
283 }
284
Log() const285 void ValidityMap::Log() const {
286 LOG(("ValidityMap::Log() - number of pairs: %zu", mMap.Length()));
287 for (uint32_t i = 0; i < mMap.Length(); i++) {
288 LOG((" (%u, %u)", mMap[i].Offset() + 0, mMap[i].Len() + 0));
289 }
290 }
291
Length() const292 uint32_t ValidityMap::Length() const { return mMap.Length(); }
293
AddPair(uint32_t aOffset,uint32_t aLen)294 void ValidityMap::AddPair(uint32_t aOffset, uint32_t aLen) {
295 ValidityPair pair(aOffset, aLen);
296
297 if (mMap.Length() == 0) {
298 mMap.AppendElement(pair);
299 return;
300 }
301
302 // Find out where to place this pair into the map, it can overlap only with
303 // one preceding pair and all subsequent pairs.
304 uint32_t pos = 0;
305 for (pos = mMap.Length(); pos > 0;) {
306 --pos;
307
308 if (mMap[pos].LessThan(pair)) {
309 // The new pair should be either inserted after pos or merged with it.
310 if (mMap[pos].CanBeMerged(pair)) {
311 // Merge with the preceding pair
312 mMap[pos].Merge(pair);
313 } else {
314 // They don't overlap, element must be placed after pos element
315 ++pos;
316 if (pos == mMap.Length()) {
317 mMap.AppendElement(pair);
318 } else {
319 mMap.InsertElementAt(pos, pair);
320 }
321 }
322
323 break;
324 }
325
326 if (pos == 0) {
327 // The new pair should be placed in front of all existing pairs.
328 mMap.InsertElementAt(0, pair);
329 }
330 }
331
332 // pos now points to merged or inserted pair, check whether it overlaps with
333 // subsequent pairs.
334 while (pos + 1 < mMap.Length()) {
335 if (mMap[pos].CanBeMerged(mMap[pos + 1])) {
336 mMap[pos].Merge(mMap[pos + 1]);
337 mMap.RemoveElementAt(pos + 1);
338 } else {
339 break;
340 }
341 }
342 }
343
Clear()344 void ValidityMap::Clear() { mMap.Clear(); }
345
SizeOfExcludingThis(mozilla::MallocSizeOf mallocSizeOf) const346 size_t ValidityMap::SizeOfExcludingThis(
347 mozilla::MallocSizeOf mallocSizeOf) const {
348 return mMap.ShallowSizeOfExcludingThis(mallocSizeOf);
349 }
350
operator [](uint32_t aIdx)351 ValidityPair& ValidityMap::operator[](uint32_t aIdx) {
352 return mMap.ElementAt(aIdx);
353 }
354
355 StaticMutex DetailedCacheHitTelemetry::sLock;
356 uint32_t DetailedCacheHitTelemetry::sRecordCnt = 0;
357 DetailedCacheHitTelemetry::HitRate
358 DetailedCacheHitTelemetry::sHRStats[kNumOfRanges];
359
HitRate()360 DetailedCacheHitTelemetry::HitRate::HitRate() { Reset(); }
361
AddRecord(ERecType aType)362 void DetailedCacheHitTelemetry::HitRate::AddRecord(ERecType aType) {
363 if (aType == HIT) {
364 ++mHitCnt;
365 } else {
366 ++mMissCnt;
367 }
368 }
369
GetHitRateBucket(uint32_t aNumOfBuckets) const370 uint32_t DetailedCacheHitTelemetry::HitRate::GetHitRateBucket(
371 uint32_t aNumOfBuckets) const {
372 uint32_t bucketIdx = (aNumOfBuckets * mHitCnt) / (mHitCnt + mMissCnt);
373 if (bucketIdx ==
374 aNumOfBuckets) { // make sure 100% falls into the last bucket
375 --bucketIdx;
376 }
377
378 return bucketIdx;
379 }
380
Count()381 uint32_t DetailedCacheHitTelemetry::HitRate::Count() {
382 return mHitCnt + mMissCnt;
383 }
384
Reset()385 void DetailedCacheHitTelemetry::HitRate::Reset() {
386 mHitCnt = 0;
387 mMissCnt = 0;
388 }
389
390 // static
AddRecord(ERecType aType,TimeStamp aLoadStart)391 void DetailedCacheHitTelemetry::AddRecord(ERecType aType,
392 TimeStamp aLoadStart) {
393 bool isUpToDate = false;
394 CacheIndex::IsUpToDate(&isUpToDate);
395 if (!isUpToDate) {
396 // Ignore the record when the entry file count might be incorrect
397 return;
398 }
399
400 uint32_t entryCount;
401 nsresult rv = CacheIndex::GetEntryFileCount(&entryCount);
402 if (NS_FAILED(rv)) {
403 return;
404 }
405
406 uint32_t rangeIdx = entryCount / kRangeSize;
407 if (rangeIdx >= kNumOfRanges) { // The last range has no upper limit.
408 rangeIdx = kNumOfRanges - 1;
409 }
410
411 uint32_t hitMissValue = 2 * rangeIdx; // 2 values per range
412 if (aType == MISS) { // The order is HIT, MISS
413 ++hitMissValue;
414 }
415
416 StaticMutexAutoLock lock(sLock);
417
418 if (aType == MISS) {
419 mozilla::Telemetry::AccumulateTimeDelta(
420 mozilla::Telemetry::NETWORK_CACHE_V2_MISS_TIME_MS, aLoadStart);
421 } else {
422 mozilla::Telemetry::AccumulateTimeDelta(
423 mozilla::Telemetry::NETWORK_CACHE_V2_HIT_TIME_MS, aLoadStart);
424 }
425
426 Telemetry::Accumulate(Telemetry::NETWORK_CACHE_HIT_MISS_STAT_PER_CACHE_SIZE,
427 hitMissValue);
428
429 sHRStats[rangeIdx].AddRecord(aType);
430 ++sRecordCnt;
431
432 if (sRecordCnt < kTotalSamplesReportLimit) {
433 return;
434 }
435
436 sRecordCnt = 0;
437
438 for (uint32_t i = 0; i < kNumOfRanges; ++i) {
439 if (sHRStats[i].Count() >= kHitRateSamplesReportLimit) {
440 // The telemetry enums are grouped by buckets as follows:
441 // Telemetry value : 0,1,2,3, ... ,19,20,21,22, ... ,398,399
442 // Hit rate bucket : 0,0,0,0, ... , 0, 1, 1, 1, ... , 19, 19
443 // Cache size range: 0,1,2,3, ... ,19, 0, 1, 2, ... , 18, 19
444 uint32_t bucketOffset =
445 sHRStats[i].GetHitRateBucket(kHitRateBuckets) * kNumOfRanges;
446
447 Telemetry::Accumulate(Telemetry::NETWORK_CACHE_HIT_RATE_PER_CACHE_SIZE,
448 bucketOffset + i);
449 sHRStats[i].Reset();
450 }
451 }
452 }
453
454 StaticMutex CachePerfStats::sLock;
455 CachePerfStats::PerfData CachePerfStats::sData[CachePerfStats::LAST];
456 uint32_t CachePerfStats::sCacheSlowCnt = 0;
457 uint32_t CachePerfStats::sCacheNotSlowCnt = 0;
458
MMA(uint32_t aTotalWeight,bool aFilter)459 CachePerfStats::MMA::MMA(uint32_t aTotalWeight, bool aFilter)
460 : mSum(0), mSumSq(0), mCnt(0), mWeight(aTotalWeight), mFilter(aFilter) {}
461
AddValue(uint32_t aValue)462 void CachePerfStats::MMA::AddValue(uint32_t aValue) {
463 if (mFilter) {
464 // Filter high spikes
465 uint32_t avg = GetAverage();
466 uint32_t stddev = GetStdDev();
467 uint32_t maxdiff = avg + (3 * stddev);
468 if (avg && aValue > avg + maxdiff) {
469 return;
470 }
471 }
472
473 if (mCnt < mWeight) {
474 // Compute arithmetic average until we have at least mWeight values
475 CheckedInt<uint64_t> newSumSq = CheckedInt<uint64_t>(aValue) * aValue;
476 newSumSq += mSumSq;
477 if (!newSumSq.isValid()) {
478 return; // ignore this value
479 }
480 mSumSq = newSumSq.value();
481 mSum += aValue;
482 ++mCnt;
483 } else {
484 CheckedInt<uint64_t> newSumSq = mSumSq - mSumSq / mCnt;
485 newSumSq += static_cast<uint64_t>(aValue) * aValue;
486 if (!newSumSq.isValid()) {
487 return; // ignore this value
488 }
489 mSumSq = newSumSq.value();
490
491 // Compute modified moving average for more values:
492 // newAvg = ((weight - 1) * oldAvg + newValue) / weight
493 mSum -= GetAverage();
494 mSum += aValue;
495 }
496 }
497
GetAverage()498 uint32_t CachePerfStats::MMA::GetAverage() {
499 if (mCnt == 0) {
500 return 0;
501 }
502
503 return mSum / mCnt;
504 }
505
GetStdDev()506 uint32_t CachePerfStats::MMA::GetStdDev() {
507 if (mCnt == 0) {
508 return 0;
509 }
510
511 uint32_t avg = GetAverage();
512 uint64_t avgSq = static_cast<uint64_t>(avg) * avg;
513 uint64_t variance = mSumSq / mCnt;
514 if (variance < avgSq) {
515 // Due to rounding error when using integer data type, it can happen that
516 // average of squares of the values is smaller than square of the average
517 // of the values. In this case fix mSumSq.
518 variance = avgSq;
519 mSumSq = variance * mCnt;
520 }
521
522 variance -= avgSq;
523 return sqrt(static_cast<double>(variance));
524 }
525
PerfData()526 CachePerfStats::PerfData::PerfData()
527 : mFilteredAvg(50, true), mShortAvg(3, false) {}
528
AddValue(uint32_t aValue,bool aShortOnly)529 void CachePerfStats::PerfData::AddValue(uint32_t aValue, bool aShortOnly) {
530 if (!aShortOnly) {
531 mFilteredAvg.AddValue(aValue);
532 }
533 mShortAvg.AddValue(aValue);
534 }
535
GetAverage(bool aFiltered)536 uint32_t CachePerfStats::PerfData::GetAverage(bool aFiltered) {
537 return aFiltered ? mFilteredAvg.GetAverage() : mShortAvg.GetAverage();
538 }
539
GetStdDev(bool aFiltered)540 uint32_t CachePerfStats::PerfData::GetStdDev(bool aFiltered) {
541 return aFiltered ? mFilteredAvg.GetStdDev() : mShortAvg.GetStdDev();
542 }
543
544 // static
AddValue(EDataType aType,uint32_t aValue,bool aShortOnly)545 void CachePerfStats::AddValue(EDataType aType, uint32_t aValue,
546 bool aShortOnly) {
547 StaticMutexAutoLock lock(sLock);
548 sData[aType].AddValue(aValue, aShortOnly);
549 }
550
551 // static
GetAverage(EDataType aType,bool aFiltered)552 uint32_t CachePerfStats::GetAverage(EDataType aType, bool aFiltered) {
553 StaticMutexAutoLock lock(sLock);
554 return sData[aType].GetAverage(aFiltered);
555 }
556
557 // static
GetStdDev(EDataType aType,bool aFiltered)558 uint32_t CachePerfStats::GetStdDev(EDataType aType, bool aFiltered) {
559 StaticMutexAutoLock lock(sLock);
560 return sData[aType].GetStdDev(aFiltered);
561 }
562
563 // static
IsCacheSlow()564 bool CachePerfStats::IsCacheSlow() {
565 StaticMutexAutoLock lock(sLock);
566
567 // Compare mShortAvg with mFilteredAvg to find out whether cache is getting
568 // slower. Use only data about single IO operations because ENTRY_OPEN can be
569 // affected by more factors than a slow disk.
570 for (uint32_t i = 0; i < ENTRY_OPEN; ++i) {
571 if (i == IO_WRITE) {
572 // Skip this data type. IsCacheSlow is used for determining cache slowness
573 // when opening entries. Writes have low priority and it's normal that
574 // they are delayed a lot, but this doesn't necessarily affect opening
575 // cache entries.
576 continue;
577 }
578
579 uint32_t avgLong = sData[i].GetAverage(true);
580 if (avgLong == 0) {
581 // We have no perf data yet, skip this data type.
582 continue;
583 }
584 uint32_t avgShort = sData[i].GetAverage(false);
585 uint32_t stddevLong = sData[i].GetStdDev(true);
586 uint32_t maxdiff = avgLong + (3 * stddevLong);
587
588 if (avgShort > avgLong + maxdiff) {
589 LOG(
590 ("CachePerfStats::IsCacheSlow() - result is slow based on perf "
591 "type %u [avgShort=%u, avgLong=%u, stddevLong=%u]",
592 i, avgShort, avgLong, stddevLong));
593 ++sCacheSlowCnt;
594 return true;
595 }
596 }
597
598 ++sCacheNotSlowCnt;
599 return false;
600 }
601
602 // static
GetSlowStats(uint32_t * aSlow,uint32_t * aNotSlow)603 void CachePerfStats::GetSlowStats(uint32_t* aSlow, uint32_t* aNotSlow) {
604 StaticMutexAutoLock lock(sLock);
605 *aSlow = sCacheSlowCnt;
606 *aNotSlow = sCacheNotSlowCnt;
607 }
608
FreeBuffer(void * aBuf)609 void FreeBuffer(void* aBuf) {
610 #ifndef NS_FREE_PERMANENT_DATA
611 if (CacheObserver::ShuttingDown()) {
612 return;
613 }
614 #endif
615
616 free(aBuf);
617 }
618
ParseAlternativeDataInfo(const char * aInfo,int64_t * _offset,nsACString * _type)619 nsresult ParseAlternativeDataInfo(const char* aInfo, int64_t* _offset,
620 nsACString* _type) {
621 // The format is: "1;12345,javascript/binary"
622 // <version>;<offset>,<type>
623 mozilla::Tokenizer p(aInfo, nullptr, "/");
624 uint32_t altDataVersion = 0;
625 int64_t altDataOffset = -1;
626
627 // The metadata format has a wrong version number.
628 if (!p.ReadInteger(&altDataVersion) || altDataVersion != kAltDataVersion) {
629 LOG(
630 ("ParseAlternativeDataInfo() - altDataVersion=%u, "
631 "expectedVersion=%u",
632 altDataVersion, kAltDataVersion));
633 return NS_ERROR_NOT_AVAILABLE;
634 }
635
636 if (!p.CheckChar(';') || !p.ReadInteger(&altDataOffset) ||
637 !p.CheckChar(',')) {
638 return NS_ERROR_NOT_AVAILABLE;
639 }
640
641 // The requested alt-data representation is not available
642 if (altDataOffset < 0) {
643 return NS_ERROR_NOT_AVAILABLE;
644 }
645
646 if (_offset) {
647 *_offset = altDataOffset;
648 }
649
650 if (_type) {
651 mozilla::Unused << p.ReadUntil(Tokenizer::Token::EndOfFile(), *_type);
652 }
653
654 return NS_OK;
655 }
656
BuildAlternativeDataInfo(const char * aInfo,int64_t aOffset,nsACString & _retval)657 void BuildAlternativeDataInfo(const char* aInfo, int64_t aOffset,
658 nsACString& _retval) {
659 _retval.Truncate();
660 _retval.AppendInt(kAltDataVersion);
661 _retval.Append(';');
662 _retval.AppendInt(aOffset);
663 _retval.Append(',');
664 _retval.Append(aInfo);
665 }
666
667 } // namespace mozilla::net::CacheFileUtils
668